From 13b0bcca6c5b523430606225db72a8649d2d5aa9 Mon Sep 17 00:00:00 2001 From: johnny Date: Mon, 29 Dec 2025 16:18:39 +0200 Subject: [PATCH 01/14] md --- comparison/appwrite.md | 557 +++++++++++++++++++++ comparison/meteor.md | 1024 ++++++++++++++++++++++++++++++++++++++ comparison/supabase.md | 709 ++++++++++++++++++++++++++ realtime-api-proposal.md | 330 ++++++++++++ 4 files changed, 2620 insertions(+) create mode 100644 comparison/appwrite.md create mode 100644 comparison/meteor.md create mode 100644 comparison/supabase.md create mode 100644 realtime-api-proposal.md diff --git a/comparison/appwrite.md b/comparison/appwrite.md new file mode 100644 index 0000000..1f328d3 --- /dev/null +++ b/comparison/appwrite.md @@ -0,0 +1,557 @@ +--- +layout: article +title: Realtime +description: Want to build dynamic and interactive applications with real-time data updates? Appwrite Realtime API makes it possible, get started with our intro guide. +--- + +Appwrite supports multiple protocols for accessing the server, including [REST](/docs/apis/rest), [GraphQL](/docs/apis/graphql), and [Realtime](/docs/apis/realtime). The Appwrite Realtime allows you to listen to any Appwrite events in realtime using the `subscribe` method. + +Instead of requesting new data via HTTP, the subscription will receive new data every time it changes, any connected client receives that update within milliseconds via a WebSocket connection. + +This lets you build an interactive and responsive user experience by providing information from all of Appwrite's services in realtime. The example below shows subscribing to realtime events for file uploads. + +{% multicode %} + +```client-web +import { Client } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +// Subscribe to files channel +client.subscribe('files', response => { + if(response.events.includes('buckets.*.files.*.create')) { + // Log when a new file is uploaded + console.log(response.payload); + } +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +final realtime = Realtime(client); + +// Subscribe to files channel +final subscription = realtime.subscribe(['files']); + +subscription.stream.listen((response) { + if(response.events.contains('buckets.*.files.*.create')) { + // Log when a new file is uploaded + print(response.payload); + } +}); +``` + +```client-apple +import Appwrite +import AppwriteModels + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +let realtime = Realtime(client) + +// Subscribe to files channel +let subscription = realtime.subscribe(channels: ["files"]) { response in + if (message.events!.contains("buckets.*.files.*.create")) { + // Log when a new file is uploaded + print(String(describing: response)) + } +} +``` + +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Realtime + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +val realtime = Realtime(client) + +// Subscribe to files channel +let subscription = realtime.subscribe("files") { + if(it.events.contains("buckets.*.files.*.create")) { + // Log when a new file is uploaded + print(it.payload.toString()); + } +} +``` + +{% /multicode %} + +To subscribe to updates from different Appwrite resources, you need to specify one or more [channels](/docs/apis/realtime#channels). The channels offer a wide and powerful selection that will allow you to listen to all possible resources. This allows you to receive updates not only from the database, but from _all_ the services that Appwrite offers. + +If you subscribe to a channel, you will receive callbacks for a variety of events related to the channel. The events column in the callback can be used to filter and respond to specific events in a channel. + +[View a list of all available events](/docs/advanced/platform/events). + +{% info title="Permissions" %} +All subscriptions are secured by the [permissions system](/docs/advanced/platform/permissions) offered by Appwrite, meaning a user will only receive updates to resources they have permission to access. + +Using `Role.any()` on read permissions will allow any client to receive updates. +{% /info %} + +# Authentication {% #authentication %} + +Realtime authenticates using an existing user session. If you authenticate **after** creating a subscription, the subscription will not receive updates for the newly authenticated user. You will need to re-create the subscription to work with the new user. + +More information and examples of authenticating users can be found in the dedicated [authentication docs](/docs/products/auth). + +# Examples {% #examples %} + +The examples below will show you how you can use Realtime in various ways. + +## Subscribe to a Channel {% #subscribe-to-a-channel %} + +In this example we are subscribing to all updates related to our account by using the `account` channel. This will be triggered by any update related to the authenticated user, like updating the user's name or e-mail address. + +{% multicode %} + +```client-web +import { Client } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +client.subscribe('account', response => { + // Callback will be executed on all account events. + console.log(response); +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +final realtime = Realtime(client); + +final subscription = realtime.subscribe(['account']); + +subscription.stream.listen((response) { + // Callback will be executed on all account events. + print(response); +}) +``` + +```client-apple +import Appwrite +import AppwriteModels + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +let realtime = Realtime(client) + +let subscription = realtime.subscribe(channel: "account", callback: { response in + // Callback will be executed on all account events. + print(String(describing: response)) +}) +``` + +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Realtime + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +val realtime = Realtime(client) + +val subscription = realtime.subscribe("account") { + // Callback will be executed on all account events. + print(it.payload.toString()) +} +``` + +{% /multicode %} + +## Subscribe to Multiple Channels {% #subscribe-to-multiple-channel %} + +You can also listen to multiple channels at once by passing an array of channels. This will trigger the callback for any events for all channels passed. + +In this example we are listening to the row A and all files by subscribing to the `databases.A.tables.A.rows.A` and `files` channels. + +{% multicode %} + +```client-web +import { Client } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +client.subscribe(['tables.A.rows.A', 'files'], response => { + // Callback will be executed on changes for rows A and all files. + console.log(response); +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +final realtime = Realtime(client); + +final subscription = realtime.subscribe(['databases.A.tables.A.rows.A', 'files']); + +subscription.stream.listen((response) { + // Callback will be executed on changes for rows A and all files. + print(response); +}) +``` + +```client-apple +import Appwrite +import AppwriteModels + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +let realtime = Realtime(client) + +realtime.subscribe(channels: ["databases.A.tables.A.rows.A", "files"]) { response in + // Callback will be executed on changes for rows A and all files. + print(String(describing: response)) +} +``` + +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Realtime + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") +val realtime = Realtime(client) + +realtime.subscribe("databases.A.tables.A.rows.A", "files") { + // Callback will be executed on changes for rows A and all files. + print(it.toString()) +} +``` + +{% /multicode %} + +## Unsubscribe {% #unsubscribe %} + +If you no longer want to receive updates from a subscription, you can unsubscribe so that your callbacks are no longer called. Leaving old subscriptions alive and resubscribing can result in duplicate subscriptions and cause race conditions. + +{% multicode %} + +```client-web +import { Client } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +const unsubscribe = client.subscribe('files', response => { + // Callback will be executed on changes for all files. + console.log(response); +}); + +// Closes the subscription. +unsubscribe(); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +final realtime = Realtime(client); + +final subscription = realtime.subscribe(['files']); + +subscription.stream.listen((response) { + // Callback will be executed on changes for all files. + print(response); +}) + +// Closes the subscription. +subscription.close(); +``` + +```client-apple +import Appwrite + +let client = Client() +let realtime = Realtime(client) + +let subscription = realtime.subscribe(channel: "files") { response in + // Callback will be executed on changes for all files. + print(response.toString()) +} + +// Closes the subscription. +subscription.close() +``` + +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Realtime + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +val realtime = Realtime(client) + +val subscription = realtime.subscribe("files") { + // Callback will be executed on changes for all files. + print(it.toString()) +} + +// Closes the subscription. +subscription.close() +``` + +{% /multicode %} + +# Payload {% #payload %} + +The payload from the subscription will contain following properties: + +{% table %} + +- Name +- Type +- Description + +--- + +- events +- string[] +- The [Appwrite events](/docs/advanced/platform/events) that triggered this update. + +--- + +- channels +- string[] +- An array of [channels](/docs/apis/realtime#channels) that can receive this message. + +--- + +- timestamp +- string +- The [ISO 8601 timestamp](https://en.wikipedia.org/wiki/ISO_8601) in UTC timezone from the server + +--- + +- payload +- object +- Payload contains the data equal to the response model. + {% /table %} + +If you subscribe to the `rows` channel and a row the user is allowed to read is updated, you will receive an object containing information about the event and the updated row. + +The response will look like this: + +```json +{ + "events": [ + "databases.default.tables.sample.rows.63c98b9baea0938e1206.update", + "databases.*.tables.*.rows.*.update", + "databases.default.tables.*.rows.63c98b9baea0938e1206.update", + "databases.*.tables.*.rows.63c98b9baea0938e1206.update", + "databases.*.tables.sample.rows.63c98b9baea0938e1206.update", + "databases.default.tables.sample.rows.*.update", + "databases.*.tables.sample.rows.*.update", + "databases.default.tables.*.rows.*.update", + "databases.default.tables.sample.rows.63c98b9baea0938e1206", + "databases.*.tables.*.rows.*", + "databases.default.tables.*.rows.63c98b9baea0938e1206", + "databases.*.tables.*.rows.63c98b9baea0938e1206", + "databases.*.tables.sample.rows.63c98b9baea0938e1206", + "databases.default.tables.sample.rows.*", + "databases.*.tables.sample.rows.*", + "databases.default.tables.*.rows.*", + "databases.default.tables.sample", + "databases.*.tables.*", + "databases.default.tables.*", + "databases.*.tables.sample", + "databases.default", + "databases.*" + ], + "channels": [ + "rows", + "databases.default.tables.sample.rows", + "databases.default.tables.sample.rows.63c98b9baea0938e1206" + ], + "timestamp": "2023-01-19 18:30:04.051", + "payload": { + "ip": "127.0.0.1", + "stringArray": ["sss"], + "email": "joe@example.com", + "stringRequired": "req", + "float": 3.3, + "boolean": false, + "integer": 3, + "enum": "apple", + "stringDefault": "default", + "datetime": "2023-01-19T10:27:09.428+00:00", + "url": "https://appwrite.io", + "$id": "63c98b9baea0938e1206", + "$createdAt": "2023-01-19T18:27:39.715+00:00", + "$updatedAt": "2023-01-19T18:30:04.040+00:00", + "$permissions": [], + "$tableId": "sample", + "$databaseId": "default" + } +} +``` + +# Channels {% #channels %} + +A list of all channels available you can subscribe to. IDs cannot be wildcards. + +{% table %} + +- Channel +- Description + +--- + +- `account` +- All account related events (session create, name update...) + +--- + +- `databases..tables..rows` +- Any create/update/delete events to any row in a table + +--- + +- `rows` +- Any create/update/delete events to any row + +--- + +- `databases..tables..rows.` +- Any update/delete events to a given row + +--- + +- `files` +- Any create/update/delete events to any file + +--- + +- `buckets..files.` +- Any update/delete events to a given file of the given bucket + +--- + +- `buckets..files` +- Any update/delete events to any file of the given bucket + +--- + +- `teams` +- Any create/update/delete events to a any team + +--- + +- `teams.` +- Any update/delete events to a given team + +--- + +- `memberships` +- Any create/update/delete events to a any membership + +--- + +- `memberships.` +- Any update/delete events to a given membership + +--- + +- `executions` +- Any update to executions + +--- + +- `executions.` +- Any update to a given execution + +--- + +- `functions.` +- Any execution event to a given function + +{% /table %} + +# Custom endpoint {% #custom-endpoint %} + +The SDK will guess the endpoint of the Realtime API when setting the endpoint of your Appwrite instance. If you are running Appwrite with a custom proxy and changed the route of the Realtime API, you can call the `setEndpointRealtime` method on the Client SDK and set your new endpoint value. + +By default the endpoint is `wss://.cloud.appwrite.io/v1/realtime`. + +{% multicode %} + +```client-web +import { Client } from "appwrite"; +const client = new Client(); + +client.setEndpointRealtime('wss://.cloud.appwrite.io/v1/realtime'); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client(); +client.setEndpointRealtime('wss://.cloud.appwrite.io/v1/realtime'); +``` + +```client-apple +import Appwrite + +let client = Client() +client.setEndpointRealtime("wss://.cloud.appwrite.io/v1/realtime") +``` + +```client-android-kotlin +import io.appwrite.Client + +val client = Client(context) +client.setEndpointRealtime("wss://.cloud.appwrite.io/v1/realtime") +``` + +{% /multicode %} + +# Limitations {% #limitations %} + +While the Realtime API offers robust capabilities, there are currently some limitations to be aware of in its implementation. + +## Subscription changes {% #subscription-changes %} + +The SDK creates a single WebSocket connection for all subscribed channels. +Each time a channel is added or unsubscribed, the SDK currently creates a completely new connection and terminates the old one. +Therefore, subscriptions to channels should always be done in conjunction with state management so as not to be unnecessarily +built up several times by multiple components' life cycles. + +## Server SDKs {% #server-sdks %} + +We currently are not offering access to realtime with Server SDKs and an API key. diff --git a/comparison/meteor.md b/comparison/meteor.md new file mode 100644 index 0000000..01d7010 --- /dev/null +++ b/comparison/meteor.md @@ -0,0 +1,1024 @@ +# Meteor API + +Meteor global object has many functions and properties for handling utilities, network and much more. + +### Core APIs {#core} + + + +On a server, the function will run as soon as the server process is +finished starting. On a client, the function will run as soon as the DOM +is ready. Code wrapped in `Meteor.startup` always runs after all app +files have loaded, so you should put code here if you want to access +shared variables from other files. + +The `startup` callbacks are called in the same order as the calls to +`Meteor.startup` were made. + +On a client, `startup` callbacks from packages will be called +first, followed by `` templates from your `.html` files, +followed by your application code. + +::: code-group + +```js [server.js] +import { Meteor } from "meteor/meteor"; +import { LinksCollection } from "/imports/api/links"; + +Meteor.startup(async () => { + // If the Links collection is empty, add some data. + if ((await LinksCollection.find().countAsync()) === 0) { + await LinksCollection.insertAsync({ + title: "Do the Tutorial", + url: "https://docs.meteor.com/tutorials/react", + }); + } +}); +``` + +```js [client.js] +import React from "react"; +import { createRoot } from "react-dom/client"; +import { Meteor } from "meteor/meteor"; +import { App } from "/imports/ui/App"; + +// Setup react root +Meteor.startup(() => { + const container = document.getElementById("react-target"); + const root = createRoot(container); + root.render(); +}); +``` + +::: + + + + + + + + + + + +::: danger +`Meteor.isServer` can be used to limit where code runs, but it does not prevent code from +being sent to the client. Any sensitive code that you don’t want served to the client, +such as code containing passwords or authentication mechanisms, +should be kept in the `server` directory. +::: + + + + + + + + + + + +### Method APIs {#methods} + +Meteor Methods are Remote Procedure Calls (RPCs) are functions defined by `Meteor.methods` +and called by [`Meteor.call`](#Meteor-call). + + + +The most basic way to define a method is to provide a function: + +::: code-group + +```js [server.js] +import { Meteor } from "meteor/meteor"; + +Meteor.methods({ + sum(a, b) { + return a + b; + }, +}); +``` + +```js [client.js] +import { Meteor } from "meteor/meteor"; + +const result = await Meteor.callAsync("sum", 1, 2); +console.log(result); // 3 +``` + +::: + +You can use `Meteor.methods` to define multiple methods at once. + +You can think of `Meteor.methods` as a way of defining a remote object that is your server API. + +A more complete example: + +::: code-group + +```js [server.js] +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { LinksCollection } from "/imports/api/links"; + +Meteor.methods({ + async addLink(link) { + check(link, String); // check if the link is a string + + // Do stuff... + const linkID = await LinksCollection.insertAsync(link); + if (/* you want to throw an error */) { + throw new Meteor.Error('Something is wrong', "Some details"); + } + + return linkID; + }, + + bar() { + // Do other stuff... + return 'baz'; + } +}); +``` + +```js [client.js] +import React from "react"; +import { Meteor } from "meteor/meteor"; + +function Component() { + const addLink = () => + Meteor.callAsync("addLink", "https://docs.meteor.com/tutorials/react/"); + + return ( +
+ +
+ ); +} +``` + +::: + +Calling `methods` on the server defines functions that can be called remotely by +clients. They should return an [EJSON](./EJSON)-able value or throw an +exception. Inside your method invocation, `this` is bound to a method +invocation object, which provides the following: + +- `isSimulation`: a boolean value, true if this invocation is a stub. +- `unblock`: when called, allows the next method from this client to + begin running. +- `userId`: the id of the current user. +- `setUserId`: a function that associates the current client with a user. +- `connection`: on the server, the [connection](#Meteor-onConnection) this method call was received on. + +Calling `methods` on the client defines _stub_ functions associated with +server methods of the same name. You don't have to define a stub for +your method if you don't want to. In that case, method calls are just +like remote procedure calls in other systems, and you'll have to wait +for the results from the server. + +If you do define a stub, when a client invokes a server method it will +also run its stub in parallel. On the client, the return value of a +stub is ignored. Stubs are run for their side-effects: they are +intended to _simulate_ the result of what the server's method will do, +but without waiting for the round trip delay. If a stub throws an +exception it will be logged to the console. + +You use methods all the time, because the database mutators +([`insert`](./collections#Mongo-Collection-insert), [`update`](./collections#Mongo-Collection-update), [`remove`](./collections#Mongo-Collection-remove)) are implemented +as methods. When you call any of these functions on the client, you're invoking +their stub version that update the local cache, and sending the same write +request to the server. When the server responds, the client updates the local +cache with the writes that actually occurred on the server. + +You don't have to put all your method definitions into a single `Meteor.methods` +call; you may call it multiple times, as long as each method has a unique name. + +If a client calls a method and is disconnected before it receives a response, +it will re-call the method when it reconnects. This means that a client may +call a method multiple times when it only means to call it once. If this +behavior is problematic for your method, consider attaching a unique ID +to each method call on the client, and checking on the server whether a call +with this ID has already been made. Alternatively, you can use +[`Meteor.apply`](#Meteor-apply) with the noRetry option set to true. + +Read more about methods and how to use them in the [Methods](http://guide.meteor.com/methods.html) article in the Meteor Guide. + + + +This method can be used to determine if the current method invocation is +asynchronous. It returns true if the method is running on the server and came from +an async call(`Meteor.callAsync`) + +::: code-group + +```js [server.js] +import { Meteor } from "meteor/meteor"; + +Meteor.methods({ + async foo() { + return Meteor.isAsyncCall(); + }, +}); +``` + +```js [client.js] +import { Meteor } from "meteor/meteor"; + +const result = await Meteor.callAsync("foo"); +console.log(result); // true + +Meteor.call("foo", (err, result) => { + console.log(result); // false +}); +``` + +::: + +## this.userId {#methods-userId} + +The user id is an arbitrary string — typically the id of the user record +in the database. You can set it with the `setUserId` function. If you're using +the [Meteor accounts system](./accounts.md) then this is handled for you. + +```js +import { Meteor } from "meteor/meteor"; + +Meteor.methods({ + foo() { + console.log(this.userId); + }, +}); +``` + +## this.setUserId {#methods-setUserId} + +Call this function to change the currently logged-in user on the +connection that made this method call. This simply sets the value of +`userId` for future method calls received on this connection. Pass +`null` to log out the connection. + +If you are using the [built-in Meteor accounts system](./accounts) then this +should correspond to the `_id` field of a document in the +[`Meteor.users`](./accounts.md#Meteor-user) collection. + +`setUserId` is not retroactive. It affects the current method call and +any future method calls on the connection. Any previous method calls on +this connection will still see the value of `userId` that was in effect +when they started. + +If you also want to change the logged-in user on the client, then after calling +`setUserId` on the server, call `Meteor.connection.setUserId(userId)` on the +client. + +```js +import { Meteor } from "meteor/meteor"; + +Meteor.methods({ + foo() { + this.setUserId("some-id"); + }, +}); +``` + +## this.connection {#methods-connection} + +Access inside a method invocation. The [connection](#Meteor-onConnection) that this method was received on. +null if the method is not associated with a connection, +eg. a server initiated method call. Calls to methods +made from a server method which was in turn initiated from the client share the same +connection. + + + +For example: + +::: code-group + +```js [server.js] +import { Meteor } from "meteor/meteor"; +// on the server, pick a code unique to this error +// the reason field should be a useful debug message +Meteor.methods({ + methodName() { + throw new Meteor.Error( + "logged-out", + "The user must be logged in to post a comment." + ); + }, +}); +``` + +```js [client.js] +import { Meteor } from "meteor/meteor"; +// on the client +Meteor.call("methodName", function (error) { + // identify the error + if (error && error.error === "logged-out") { + // show a nice error message + Session.set("errorMessage", "Please log in to post a comment."); + } +}); +``` + +::: + +If you want to return an error from a method, throw an exception. Methods can +throw any kind of exception. But `Meteor.Error` is the only kind of error that +a server will send to the client. If a method function throws a different +exception, then it will be mapped to a sanitized version on the +wire. Specifically, if the `sanitizedError` field on the thrown error is set to +a `Meteor.Error`, then that error will be sent to the client. Otherwise, if no +sanitized version is available, the client gets +`Meteor.Error(500, 'Internal server error')`. + + + +This is how to invoke a method with a sync stub. It will run the method on the server. If a +stub is available, it will also run the stub on the client. (See also +[`Meteor.apply`](#Meteor-apply), which is identical to `Meteor.call` except that +you specify the parameters as an array instead of as separate arguments and you +can specify a few options controlling how the method is executed.) + +If you include a callback function as the last argument (which can't be +an argument to the method, since functions aren't serializable), the +method will run asynchronously: it will return nothing in particular and +will not throw an exception. When the method is complete (which may or +may not happen before `Meteor.call` returns), the callback will be +called with two arguments: `error` and `result`. If an error was thrown, +then `error` will be the exception object. Otherwise, `error` will be +`undefined` and the return value (possibly `undefined`) will be in `result`. + +```js +// Asynchronous call +Meteor.call('foo', 1, 2, (error, result) => { ... }); +``` + +If you do not pass a callback on the server, the method invocation will +block until the method is complete. It will eventually return the +return value of the method, or it will throw an exception if the method +threw an exception. (Possibly mapped to 500 Server Error if the +exception happened remotely and it was not a `Meteor.Error` exception.) + +```js +// Synchronous call +const result = Meteor.call("foo", 1, 2); +``` + +On the client, if you do not pass a callback and you are not inside a +stub, `call` will return `undefined`, and you will have no way to get +the return value of the method. That is because the client doesn't have +fibers, so there is not actually any way it can block on the remote +execution of a method. + +Finally, if you are inside a stub on the client and call another +method, the other method is not executed (no RPC is generated, nothing +"real" happens). If that other method has a stub, that stub stands in +for the method and is executed. The method call's return value is the +return value of the stub function. The client has no problem executing +a stub synchronously, and that is why it's okay for the client to use +the synchronous `Meteor.call` form from inside a method body, as +described earlier. + +Meteor tracks the database writes performed by methods, both on the client and +the server, and does not invoke `asyncCallback` until all of the server's writes +replace the stub's writes in the local cache. In some cases, there can be a lag +between the method's return value being available and the writes being visible: +for example, if another method still outstanding wrote to the same document, the +local cache may not be up to date until the other method finishes as well. If +you want to process the method's result as soon as it arrives from the server, +even if the method's writes are not available yet, you can specify an +`onResultReceived` callback to [`Meteor.apply`](#Meteor-apply). + +::: warning +Use `Meteor.call` only to call methods that do not have a stub, or have a sync stub. If you want to call methods with an async stub, `Meteor.callAsync` can be used with any method. +::: + + + +`Meteor.callAsync` is just like `Meteor.call`, except that it'll return a promise that you need to solve to get the server result. Along with the promise returned by `callAsync`, you can also handle `stubPromise` and `serverPromise` for managing client-side simulation and server response. + +The following sections guide you in understanding these promises and how to manage them effectively. + +#### serverPromise + +```javascript +try { + await Meteor.callAsync("greetUser", "John"); + // 🟢 Server ended with success +} catch (e) { + console.error("Error:", error.reason); // 🔴 Server ended with error +} + +Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available +``` + +#### stubPromise + +```javascript +await Meteor.callAsync("greetUser", "John").stubPromise; + +// 🔵 Client simulation +Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) +``` + +#### stubPromise and serverPromise + +```javascript +const { stubPromise, serverPromise } = Meteor.callAsync("greetUser", "John"); + +await stubPromise; + +// 🔵 Client simulation +Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) + +try { + await serverPromise; + // 🟢 Server ended with success +} catch (e) { + console.error("Error:", error.reason); // 🔴 Server ended with error +} + +Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available +``` + +#### Meteor 2.x contrast + +For those familiar with legacy Meteor 2.x, the handling of client simulation and server response was managed using fibers, as explained in the following section. This comparison illustrates how async inclusion with standard promises has transformed the way Meteor operates in modern versions. + +```javascript +Meteor.call("greetUser", "John", function (error, result) { + if (error) { + console.error("Error:", error.reason); // 🔴 Server ended with error + } else { + console.log("Result:", result); // 🟢 Server ended with success + } + + Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available +}); + +// 🔵 Client simulation +Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) +``` + + + +`Meteor.apply` is just like `Meteor.call`, except that the method arguments are +passed as an array rather than directly as arguments, and you can specify +options about how the client executes the method. + +::: warning +Use `Meteor.apply` only to call methods that do not have a stub, or have a sync stub. If you want to call methods with an async stub, `Meteor.applyAsync` can be used with any method. +::: + + + +`Meteor.applyAsync` is just like `Meteor.apply`, except it is an async function, and it will consider that the stub is async. + +### Publish and subscribe {#pubsub} + +These functions control how Meteor servers publish sets of records and +how clients can subscribe to those sets. + + +To publish records to clients, call `Meteor.publish` on the server with +two parameters: the name of the record set, and a _publish function_ +that Meteor will call each time a client subscribes to the name. + +Publish functions can return a +[`Collection.Cursor`](./collections.md#mongo_cursor), in which case Meteor +will publish that cursor's documents to each subscribed client. You can +also return an array of `Collection.Cursor`s, in which case Meteor will +publish all of the cursors. + +::: warning +If you return multiple cursors in an array, they currently must all be from +different collections. We hope to lift this restriction in a future release. +::: + + + +```js +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { Rooms } from "/imports/api/Rooms"; +import { Messages } from "/imports/api/Messages"; + +// Server: Publish the `Rooms` collection, minus secret info... +Meteor.publish("rooms", function () { + return Rooms.find( + {}, + { + fields: { secretInfo: 0 }, + } + ); +}); + +// ...and publish secret info for rooms where the logged-in user is an admin. If +// the client subscribes to both publications, the records are merged together +// into the same documents in the `Rooms` collection. Note that currently object +// values are not recursively merged, so the fields that differ must be top +// level fields. +Meteor.publish("adminSecretInfo", function () { + return Rooms.find( + { admin: this.userId }, + { + fields: { secretInfo: 1 }, + } + ); +}); + +// Publish dependent documents and simulate joins. +Meteor.publish("roomAndMessages", function (roomId) { + check(roomId, String); + + return [ + Rooms.find( + { _id: roomId }, + { + fields: { secretInfo: 0 }, + } + ), + Messages.find({ roomId }), + ]; +}); +``` + +Alternatively, a publish function can directly control its published record set +by calling the functions [`added`](#Subscription-added) (to add a new document to the +published record set), [`changed`](#Subscription-changed) (to change or clear some +fields on a document already in the published record set), and +[`removed`](#Subscription-removed) (to remove documents from the published record +set). These methods are provided by `this` in your publish function. + +If a publish function does not return a cursor or array of cursors, it is +assumed to be using the low-level `added`/`changed`/`removed` interface, and it +**must also call [`ready`](#Subscription-ready) once the initial record set is +complete**. + +::: code-group + +```js [collections.js] +import { Mongo } from "meteor/mongo"; + +export const Rooms = new Mongo.Collection("rooms"); +export const SecretData = new Mongo.Collection("messages"); +``` + +```js [server.js] +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { Rooms, SecretData } from "/imports/api/collections"; + +// Publish the current size of a collection. +Meteor.publish("countsByRoom", function (roomId) { + check(roomId, String); + + let count = 0; + let initializing = true; + + // `observeChanges` only returns after the initial `added` callbacks have run. + // Until then, we don't want to send a lot of `changed` messages—hence + // tracking the `initializing` state. + const handle = Messages.find({ roomId }).observeChanges({ + added: (id) => { + count += 1; + + if (!initializing) { + this.changed("counts", roomId, { count }); + } + }, + + removed: (id) => { + count -= 1; + this.changed("counts", roomId, { count }); + }, + + // We don't care about `changed` events. + }); + + // Instead, we'll send one `added` message right after `observeChanges` has + // returned, and mark the subscription as ready. + initializing = false; + this.added("counts", roomId, { count }); + this.ready(); + + // Stop observing the cursor when the client unsubscribes. Stopping a + // subscription automatically takes care of sending the client any `removed` + // messages. + this.onStop(() => handle.stop()); +}); + +// Sometimes publish a query, sometimes publish nothing. +Meteor.publish("secretData", function () { + if (this.userId === "superuser") { + return SecretData.find(); + } else { + // Declare that no data is being published. If you leave this line out, + // Meteor will never consider the subscription ready because it thinks + // you're using the `added/changed/removed` interface where you have to + // explicitly call `this.ready`. + return []; + } +}); +``` + +```js [client.js] +import { Meteor } from "meteor/meteor"; +import { Mongo } from "meteor/mongo"; +import { Session } from "meteor/session"; +// Declare a collection to hold the count object. +const Counts = new Mongo.Collection("counts"); + +// Subscribe to the count for the current room. +Tracker.autorun(() => { + Meteor.subscribe("countsByRoom", Session.get("roomId")); +}); + +// Use the new collection. +const roomCount = Counts.findOne(Session.get("roomId")).count; +console.log(`Current room has ${roomCount} messages.`); +``` + +::: warning + +Meteor will emit a warning message if you call `Meteor.publish` in a +project that includes the `autopublish` package. Your publish function +will still work. + +::: + +Read more about publications and how to use them in the +[Data Loading](http://guide.meteor.com/data-loading.html) article in the Meteor Guide. + + + +This is constant. However, if the logged-in user changes, the publish +function is rerun with the new value, assuming it didn't throw an error at the previous run. + + + + + + + +If you call [`observe`](./collections.md#Mongo-Cursor-observe) or [`observeChanges`](./collections.md#Mongo-Cursor-observeChanges) in your +publish handler, this is the place to stop the observes. + + + + + + + +When you subscribe to a record set, it tells the server to send records to the +client. The client stores these records in local [Minimongo collections](./collections.md), with the same name as the `collection` +argument used in the publish handler's [`added`](#Subscription-added), +[`changed`](#Subscription-changed), and [`removed`](#Subscription-removed) +callbacks. Meteor will queue incoming records until you declare the +[`Mongo.Collection`](./collections.md) on the client with the matching +collection name. + +```js +// It's okay to subscribe (and possibly receive data) before declaring the +// client collection that will hold it. Assume 'allPlayers' publishes data from +// the server's 'players' collection. +Meteor.subscribe("allPlayers"); + +// The client queues incoming 'players' records until the collection is created: +const Players = new Mongo.Collection("players"); +``` + +The client will see a document if the document is currently in the published +record set of any of its subscriptions. If multiple publications publish a +document with the same `_id` for the same collection the documents are merged for +the client. If the values of any of the top level fields conflict, the resulting +value will be one of the published values, chosen arbitrarily. + +::: warning +Currently, when multiple subscriptions publish the same document _only the top +level fields_ are compared during the merge. This means that if the documents +include different sub-fields of the same top level field, not all of them will +be available on the client. We hope to lift this restriction in a future release. +::: + +The `onReady` callback is called with no arguments when the server [marks the subscription as ready](#Subscription-ready). The `onStop` callback is called with +a [`Meteor.Error`](#Meteor-Error) if the subscription fails or is terminated by +the server. If the subscription is stopped by calling `stop` on the subscription +handle or inside the publication, `onStop` is called with no arguments. + +`Meteor.subscribe` returns a subscription handle, which is an object with the +following properties: + +```ts +import { Meteor } from "meteor/meteor"; +const handle = Meteor.subscribe("allPlayers"); + +handle.ready(); // True when the server has marked the subscription as ready + +handle.stop(); // Stop this subscription and unsubscribe from the server + +handle.subscriptionId; // The id of the subscription this handle is for. +``` + +When you run Meteor.subscribe inside of Tracker.autorun, the handles you get will always have the same subscriptionId field. +You can use this to deduplicate subscription handles if you are storing them in some data structure. + +If you call `Meteor.subscribe` within a reactive computation, +for example using +[`Tracker.autorun`](./Tracker#Tracker-autorun), the subscription will automatically be +cancelled when the computation is invalidated or stopped; it is not necessary +to call `stop` on +subscriptions made from inside `autorun`. However, if the next iteration +of your run function subscribes to the same record set (same name and +parameters), Meteor is smart enough to skip a wasteful +unsubscribe/resubscribe. For example: + +```js +Tracker.autorun(() => { + Meteor.subscribe("chat", { room: Session.get("currentRoom") }); + Meteor.subscribe("privateMessages"); +}); +``` + +This subscribes you to the chat messages in the current room and to your private +messages. When you change rooms by calling `Session.set('currentRoom', +'newRoom')`, Meteor will subscribe to the new room's chat messages, +unsubscribe from the original room's chat messages, and continue to +stay subscribed to your private messages. + +## Publication strategies + +> The following features are available from Meteor 2.4 or `ddp-server@2.5.0` + +Once you start scaling your application you might want to have more control on how the data from publications is being handled on the client. +There are three publications strategies: + +#### SERVER_MERGE + +`SERVER_MERGE` is the default strategy. When using this strategy, the server maintains a copy of all data a connection is subscribed to. +This allows us to only send deltas over multiple publications. + +#### NO_MERGE_NO_HISTORY + +The `NO_MERGE_NO_HISTORY` strategy results in the server sending all publication data directly to the client. +It does not remember what it has previously sent to client and will not trigger removed messages when a subscription is stopped. +This should only be chosen for special use cases like send-and-forget queues. + +#### NO_MERGE + +`NO_MERGE` is similar to `NO_MERGE_NO_HISTORY` but the server will remember the IDs it has +sent to the client so it can remove them when a subscription is stopped. +This strategy can be used when a collection is only used in a single publication. + +When `NO_MERGE` is selected the client will be handling gracefully duplicate events without throwing an exception. +Specifically: + +- When we receive an added message for a document that is already present in the client's collection, it will be changed. +- When we receive a change message for a document that is not in the client's collection, it will be added. +- When we receive a removed message for a document that is not in the client's collection, nothing will happen. + +You can import the publication strategies from `DDPServer`. + +```js +import { DDPServer } from "meteor/ddp-server"; + +const { SERVER_MERGE, NO_MERGE_NO_HISTORY, NO_MERGE } = + DDPServer.publicationStrategies; +``` + +You can use the following methods to set or get the publication strategy for publications: + + + +For the `foo` collection, you can set the `NO_MERGE` strategy as shown: + +```js +import { DDPServer } from "meteor/ddp-server"; +Meteor.server.setPublicationStrategy( + "foo", + DDPServer.publicationStrategies.NO_MERGE +); +``` + + + +### Server connections {#connections} + +Functions to manage and inspect the network connection between the Meteor client and server. + + + +```js +import { Meteor } from "meteor/meteor"; +const status = Meteor.status(); + +console.log(status); +// ^^^^ +// { +// connected: Boolean, +// status: String, +// retryCount: Number, +// retryTime: Number, +// reason: String, +// } +``` + +Status object has the following fields: + +- `connected` - _*Boolean*_ : True if currently connected to the server. If false, changes and + method invocations will be queued up until the connection is reestablished. +- `status` - _*String*_: Describes the current reconnection status. The possible + values are `connected` (the connection is up and + running), `connecting` (disconnected and trying to open a + new connection), `failed` (permanently failed to connect; e.g., the client + and server support different versions of DDP), `waiting` (failed + to connect and waiting to try to reconnect) and `offline` (user has disconnected the connection). +- `retryCount` - _*Number*_: The number of times the client has tried to reconnect since the + connection was lost. 0 when connected. +- `retryTime` - _*Number or undefined*_: The estimated time of the next reconnection attempt. To turn this + into an interval until the next reconnection, This key will be set only when `status` is `waiting`. + You canuse this snippet: + ```js + retryTime - new Date().getTime(); + ``` +- `reason` - _*String or undefined*_: If `status` is `failed`, a description of why the connection failed. + + + + + +Call this method to disconnect from the server and stop all +live data updates. While the client is disconnected it will not receive +updates to collections, method calls will be queued until the +connection is reestablished, and hot code push will be disabled. + +Call [Meteor.reconnect](#Meteor-reconnect) to reestablish the connection +and resume data transfer. + +This can be used to save battery on mobile devices when real time +updates are not required. + + + +```js +import { Meteor } from "meteor/meteor"; + +const handle = Meteor.onConnection((connection) => { + console.log(connection); + // ^^^^^^^^^^^ + // { + // id: String, + // close: Function, + // onClose: Function, + // clientAddress: String, + // httpHeaders: Object, + // } +}); + +handle.stop(); // Unregister the callback +``` + +`onConnection` returns an object with a single method `stop`. Calling +`stop` unregisters the callback, so that this callback will no longer +be called on new connections. + +The callback is called with a single argument, the server-side +`connection` representing the connection from the client. This object +contains the following fields: + +- `id` - _*String*_: A globally unique id for this connection. +- `close` - _*Function*_: Close this DDP connection. The client is free to reconnect, but will + receive a different connection with a new `id` if it does. +- `onClose` - _*Function*_: Register a callback to be called when the connection is closed. + If the connection is already closed, the callback will be called immediately. +- `clientAddress` - _*String*_: The IP address of the client in dotted form (such as `127.0.0.1`). If you're running your Meteor server behind a proxy (so that clients + are connecting to the proxy instead of to your server directly), + you'll need to set the `HTTP_FORWARDED_COUNT` environment variable + for the correct IP address to be reported by `clientAddress`. + + Set `HTTP_FORWARDED_COUNT` to an integer representing the number of + proxies in front of your server. For example, you'd set it to `1` + when your server was behind one proxy. + +- `httpHeaders` - _*Object*_: When the connection came in over an HTTP transport (such as with + Meteor's default SockJS implementation), this field contains + whitelisted HTTP headers. + + Cookies are deliberately excluded from the headers as they are a + security risk for this transport. For details and alternatives, see + the [SockJS documentation](https://github.com/sockjs/sockjs-node#authorisation). + +> Currently when a client reconnects to the server (such as after +> temporarily losing its Internet connection), it will get a new +> connection each time. The `onConnection` callbacks will be called +> again, and the new connection will have a new connection `id`. + +> In the future, when client reconnection is fully implemented, +> reconnecting from the client will reconnect to the same connection on +> the server: the `onConnection` callback won't be called for that +> connection again, and the connection will still have the same +> connection `id`. + + + +```js +import { DDP } from "meteor/ddp-client"; +import { Mongo } from "meteor/mongo"; +import { Meteor } from "meteor/meteor"; +const options = {...}; + +const otherServer = DDP.connect("http://example.com", options); + +otherServer.call("foo.from.other.server", 1, 2, function (err, result) { + // ... +}); + +Metepr.call("foo.from.this.server", 1, 2, function (err, result) { + // ... +}); +const remoteColl = new Mongo.Collection("collectionName", { connection: otherServer }); +remoteColl.find(...); + + +``` + +To call methods on another Meteor application or subscribe to its data +sets, call `DDP.connect` with the URL of the application. +`DDP.connect` returns an object which provides: + +- `subscribe` - + Subscribe to a record set. See + [Meteor.subscribe](#Meteor-subscribe). +- `call` - + Invoke a method. See [Meteor.call](#Meteor-call). +- `apply` - + Invoke a method with an argument array. See + [Meteor.apply](#Meteor-apply). +- `methods` - + Define client-only stubs for methods defined on the remote server. See + [Meteor.methods](#Meteor-methods). +- `status` - + Get the current connection status. See + [Meteor.status](#Meteor-status). +- `reconnect` - + See [Meteor.reconnect](#Meteor-reconnect). +- `disconnect` - + See [Meteor.disconnect](#Meteor-disconnect). + +By default, clients open a connection to the server from which they're loaded. +When you call `Meteor.subscribe`, `Meteor.status`, `Meteor.call`, and +`Meteor.apply`, you are using a connection back to that default +server. + + + +## Timers { #timers } + +Meteor uses global environment variables +to keep track of things like the current request's user. To make sure +these variables have the right values, you need to use +`Meteor.setTimeout` instead of `setTimeout` and `Meteor.setInterval` +instead of `setInterval`. + +These functions work just like their native JavaScript equivalents. +If you call the native function, you'll get an error stating that Meteor +code must always run within a Fiber, and advising to use +`Meteor.bindEnvironment`. + + + +Returns a handle that can be used by `Meteor.clearTimeout`. + + + +Returns a handle that can be used by `Meteor.clearInterval`. + + + + +## Enviroment variables {#envs} + +Meteor implements `Meteor.EnvironmentVariable` with AsyncLocalStorage, which allows for maintaining context across asynchronous boundaries. `Meteor.EnvironmentVariable` works with `Meteor.bindEnvironment`, promises, and many other Meteor API's to preserve the context in async code. Some examples of how it is used in Meteor are to store the current user in methods, and record which arguments have been checked when using `audit-argument-checks`. + +```js +import { Meteor } from "meteor/meteor"; +const currentRequest = new Meteor.EnvironmentVariable(); + +function log(message) { + const requestId = currentRequest.get() || "None"; + console.log(`[${requestId}]`, message); +} + +currentRequest.withValue("12345", () => { + log("Handling request"); // Logs: [12345] Handling request +}); +``` + + + + + + + diff --git a/comparison/supabase.md b/comparison/supabase.md new file mode 100644 index 0000000..3e25d6f --- /dev/null +++ b/comparison/supabase.md @@ -0,0 +1,709 @@ +--- +id: "getting-started" +title: "Getting Started with Realtime" +description: "Learn how to build real-time applications with Supabase Realtime" +subtitle: "Learn how to build real-time applications with Supabase Realtime" +sidebar_label: "Getting Started" +--- + +## Quick start + +### 1. Install the client library + + + +```bash +npm install @supabase/supabase-js +``` + + +<$Show if="sdk:dart"> + + +```bash +flutter pub add supabase_flutter +``` + + + +<$Show if="sdk:swift"> + + +```swift +let package = Package( + // ... + dependencies: [ + // ... + .package( + url: "https://github.com/supabase/supabase-swift.git", + from: "2.0.0" + ), + ], + targets: [ + .target( + name: "YourTargetName", + dependencies: [ + .product( + name: "Supabase", + package: "supabase-swift" + ), + ] + ) + ] +) +``` + + + +<$Show if="sdk:python"> + + +```bash +pip install supabase +``` + + + + +```bash +conda install -c conda-forge supabase +``` + + + + + +### 2. Initialize the client + +Get your project URL and key. +<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> + + + +```ts +import { createClient } from "@supabase/supabase-js"; + +const supabase = createClient( + "https://.supabase.co", + "" +); +``` + + +<$Show if="sdk:dart"> + + +```dart +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() async { + await Supabase.initialize( + url: 'https://.supabase.co', + anonKey: '', + ); + runApp(MyApp()); +} + +final supabase = Supabase.instance.client; +``` + + + +<$Show if="sdk:swift"> + + +```swift +import Supabase + +let supabase = SupabaseClient( + supabaseURL: URL(string: "https://.supabase.co")!, + supabaseKey: "" +) +``` + + + +<$Show if="sdk:python"> + + +```python +from supabase import create_client, Client + +url: str = "https://.supabase.co" +key: str = "" +supabase: Client = create_client(url, key) +``` + + + + + +### 3. Create your first Channel + +Channels are the foundation of Realtime. Think of them as rooms where clients can communicate. Each channel is identified by a topic name and if they are public or private. + + + +```ts +// Create a channel with a descriptive topic name +const channel = supabase.channel("room:lobby:messages", { + config: { private: true }, // Recommended for production +}); +``` + + +<$Show if="sdk:dart"> + + +```dart +// Create a channel with a descriptive topic name +final channel = supabase.channel('room:lobby:messages'); +``` + + + +<$Show if="sdk:swift"> + + +```swift +// Create a channel with a descriptive topic name +let channel = supabase.channel("room:lobby:messages") { + $0.isPrivate = true +} +``` + + + +<$Show if="sdk:python"> + + +```python +# Create a channel with a descriptive topic name +channel = supabase.channel('room:lobby:messages', params={config={private= True }}) +``` + + + + + +### 4. Set up authorization + +Since we're using a private channel, you need to create a basic RLS policy on the `realtime.messages` table to allow authenticated users to connect. Row Level Security (RLS) policies control who can access your Realtime channels based on user authentication and custom rules: + +```sql +-- Allow authenticated users to receive broadcasts +CREATE POLICY "authenticated_users_can_receive" ON realtime.messages + FOR SELECT TO authenticated USING (true); + +-- Allow authenticated users to send broadcasts +CREATE POLICY "authenticated_users_can_send" ON realtime.messages + FOR INSERT TO authenticated WITH CHECK (true); +``` + +### 5. Send and receive messages + +There are three main ways to send messages with Realtime: + +#### 5.1 using client libraries + +Send and receive messages using the Supabase client: + + + +```ts +// Listen for messages +channel + .on("broadcast", { event: "message_sent" }, (payload: { payload: any }) => { + console.log("New message:", payload.payload); + }) + .subscribe(); + +// Send a message +channel.send({ + type: "broadcast", + event: "message_sent", + payload: { + text: "Hello, world!", + user: "john_doe", + timestamp: new Date().toISOString(), + }, +}); +``` + + +<$Show if="sdk:dart"> + + +```dart +// Listen for messages +channel.onBroadcast( + event: 'message_sent', + callback: (payload) { + print('New message: ${payload['payload']}'); + }, +).subscribe(); + +// Send a message +channel.sendBroadcastMessage( + event: 'message_sent', + payload: { + 'text': 'Hello, world!', + 'user': 'john_doe', + 'timestamp': DateTime.now().toIso8601String(), + }, +); +``` + + + +<$Show if="sdk:swift"> + + +```swift +// Listen for messages +await channel.onBroadcast(event: "message_sent") { message in + print("New message: \(message.payload)") +} + +let status = await channel.subscribe() + +// Send a message +await channel.sendBroadcastMessage( + event: "message_sent", + payload: [ + "text": "Hello, world!", + "user": "john_doe", + "timestamp": ISO8601DateFormatter().string(from: Date()) + ] +) +``` + + + +<$Show if="sdk:python"> + + +```python +# Listen for messages +def message_handler(payload): + print(f"New message: {payload['payload']}") + +channel.on_broadcast(event="message_sent", callback=message_handler).subscribe() + +# Send a message +channel.send_broadcast_message( + event="message_sent", + payload={ + "text": "Hello, world!", + "user": "john_doe", + "timestamp": datetime.now().isoformat() + } +) +``` + + + + + +#### 5.2 using HTTP/REST API + +Send messages via HTTP requests, perfect for server-side applications: + + + +```ts +// Send message via REST API +const response = await fetch( + `https://.supabase.co/rest/v1/rpc/broadcast`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer `, + apikey: "", + }, + body: JSON.stringify({ + topic: "room:lobby:messages", + event: "message_sent", + payload: { + text: "Hello from server!", + user: "system", + timestamp: new Date().toISOString(), + }, + private: true, + }), + } +); +``` + + +<$Show if="sdk:dart"> + + +```dart +import 'package:http/http.dart' as http; +import 'dart:convert'; + +// Send message via REST API +final response = await http.post( + Uri.parse('https://.supabase.co/rest/v1/rpc/broadcast'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + 'apikey': '', + }, + body: jsonEncode({ + 'topic': 'room:lobby:messages', + 'event': 'message_sent', + 'payload': { + 'text': 'Hello from server!', + 'user': 'system', + 'timestamp': DateTime.now().toIso8601String(), + }, + 'private': true, + }), +); +``` + + + +<$Show if="sdk:swift"> + + +```swift +import Foundation + +// Send message via REST API +let url = URL(string: "https://.supabase.co/rest/v1/rpc/broadcast")! +var request = URLRequest(url: url) +request.httpMethod = "POST" +request.setValue("application/json", forHTTPHeaderField: "Content-Type") +request.setValue("Bearer ", forHTTPHeaderField: "Authorization") +request.setValue("", forHTTPHeaderField: "apikey") + +let payload = [ + "topic": "room:lobby:messages", + "event": "message_sent", + "payload": [ + "text": "Hello from server!", + "user": "system", + "timestamp": ISO8601DateFormatter().string(from: Date()) + ], + "private": true +] as [String: Any] + +request.httpBody = try JSONSerialization.data(withJSONObject: payload) + +let (data, response) = try await URLSession.shared.data(for: request) +``` + + + +<$Show if="sdk:python"> + + +```python +import requests +from datetime import datetime + +# Send message via REST API +response = requests.post( + 'https://.supabase.co/rest/v1/rpc/broadcast', + headers={ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + 'apikey': '' + }, + json={ + 'topic': 'room:lobby:messages', + 'event': 'message_sent', + 'payload': { + 'text': 'Hello from server!', + 'user': 'system', + 'timestamp': datetime.now().isoformat() + }, + 'private': True + } +) +``` + + + + + +#### 5.3 using database triggers + +Automatically broadcast database changes using triggers. Choose the approach that best fits your needs: + +**Using `realtime.broadcast_changes` (Best for mirroring database changes)** + +```sql +-- Create a trigger function for broadcasting database changes +CREATE OR REPLACE FUNCTION broadcast_message_changes() +RETURNS TRIGGER AS $$ +BEGIN + -- Broadcast to room-specific channel + PERFORM realtime.broadcast_changes( + 'room:' || NEW.room_id::text || ':messages', + TG_OP, + TG_OP, + TG_TABLE_NAME, + TG_TABLE_SCHEMA, + NEW, + OLD + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Apply trigger to your messages table +CREATE TRIGGER messages_broadcast_trigger + AFTER INSERT OR UPDATE OR DELETE ON messages + FOR EACH ROW EXECUTE FUNCTION broadcast_message_changes(); +``` + +**Using `realtime.send` (Best for custom notifications and filtered data)** + +```sql +-- Create a trigger function for custom notifications +CREATE OR REPLACE FUNCTION notify_message_activity() +RETURNS TRIGGER AS $$ +BEGIN + -- Send custom notification when new message is created + IF TG_OP = 'INSERT' THEN + PERFORM realtime.send( + 'room:' || NEW.room_id::text || ':notifications', + 'message_created', + jsonb_build_object( + 'message_id', NEW.id, + 'user_id', NEW.user_id, + 'room_id', NEW.room_id, + 'created_at', NEW.created_at + ), + true -- private channel + ); + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Apply trigger to your messages table +CREATE TRIGGER messages_notification_trigger + AFTER INSERT ON messages + FOR EACH ROW EXECUTE FUNCTION notify_message_activity(); +``` + +- **`realtime.broadcast_changes`** sends the full database change with metadata +- **`realtime.send`** allows you to send custom payloads and control exactly what data is broadcast + +## Essential best practices + +### Use private channels + +Always use private channels for production applications to ensure proper security and authorization: + +```ts +const channel = supabase.channel("room:123:messages", { + config: { private: true }, +}); +``` + +### Follow naming conventions + +**Channel Topics:** Use the pattern `scope:id:entity` + +- `room:123:messages` - Messages in room 123 +- `game:456:moves` - Game moves for game 456 +- `user:789:notifications` - Notifications for user 789 + +### Clean up subscriptions + +Always unsubscribe when you are done with a channel to ensure you free up resources: + + + +```ts +// React example +import { useEffect } from "react"; + +useEffect(() => { + const channel = supabase.channel("room:123:messages"); + + return () => { + supabase.removeChannel(channel); + }; +}, []); +``` + + +<$Show if="sdk:dart"> + + +```dart +// Flutter example +class _MyWidgetState extends State { + RealtimeChannel? _channel; + + @override + void initState() { + super.initState(); + _channel = supabase.channel('room:123:messages'); + } + + @override + void dispose() { + _channel?.unsubscribe(); + super.dispose(); + } +} +``` + + + +<$Show if="sdk:swift"> + + +```swift +// SwiftUI example +struct ContentView: View { + @State private var channel: RealtimeChannelV2? + + var body: some View { + // Your UI here + .onAppear { + channel = supabase.realtimeV2.channel("room:123:messages") + } + .onDisappear { + Task { + await channel?.unsubscribe() + } + } + } +} +``` + + + +<$Show if="sdk:python"> + + +```python +# Python example with context manager +class RealtimeManager: + def __init__(self): + self.channel = None + + def __enter__(self): + self.channel = supabase.channel('room:123:messages') + return self.channel + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.channel: + self.channel.unsubscribe() + +# Usage +with RealtimeManager() as channel: + # Use channel here + pass +``` + + + + + +## Choose the right feature + +### When to use Broadcast + +- Real-time messaging and notifications +- Custom events and game state +- Database change notifications (with triggers) +- High-frequency updates (e.g. Cursor tracking) +- Most use cases + +### When to use Presence + +- User online/offline status +- Active user counters +- Use minimally due to computational overhead + +### When to use Postgres Changes + +- Quick testing and development +- Low amount of connected users + +## Next steps + +Now that you understand the basics, dive deeper into each feature: + +### Core features + +- **[Broadcast](/docs/guides/realtime/broadcast)** - Learn about sending messages, database triggers, and REST API usage +- **[Presence](/docs/guides/realtime/presence)** - Implement user state tracking and online indicators +- **[Postgres Changes](/docs/guides/realtime/postgres-changes)** - Understanding database change listeners (consider migrating to Broadcast) + +### Security & configuration + +- **[Authorization](/docs/guides/realtime/authorization)** - Set up RLS policies for private channels +- **[Settings](/docs/guides/realtime/settings)** - Configure your Realtime instance for optimal performance + +### Advanced topics + +- **[Architecture](/docs/guides/realtime/architecture)** - Understand how Realtime works under the hood +- **[Benchmarks](/docs/guides/realtime/benchmarks)** - Performance characteristics and scaling considerations +- **[Quotas](/docs/guides/realtime/quotas)** - Usage limits and best practices + +### Integration guides + +- **[Realtime with Next.js](/docs/guides/realtime/realtime-with-nextjs)** - Build real-time Next.js applications +- **[User Presence](/docs/guides/realtime/realtime-user-presence)** - Implement user presence features +- **[Database Changes](/docs/guides/realtime/subscribing-to-database-changes)** - Listen to database changes + +### Framework examples + +- **[Flutter Integration](/docs/guides/realtime/realtime-listening-flutter)** - Build real-time Flutter applications + +Ready to build something amazing? Start with the [Broadcast guide](/docs/guides/realtime/broadcast) to create your first real-time feature! diff --git a/realtime-api-proposal.md b/realtime-api-proposal.md new file mode 100644 index 0000000..f423111 --- /dev/null +++ b/realtime-api-proposal.md @@ -0,0 +1,330 @@ +# Realtime API Proposal + +This document outlines the proposed public API for realtime entity subscriptions in the Base44 SDK. + +## Overview + +The realtime API enables users to subscribe to live updates on entities. It supports: + +- **All entity changes**: Subscribe to any create/update/delete on an entity type +- **Single entity instance**: Subscribe to changes on a specific entity by ID +- **Query-based subscriptions**: Subscribe to entities matching a filter (e.g., all completed tasks) + +--- + +## Type Definitions + +```typescript +// In entities.types.ts + +/** + * Event types for realtime entity updates. + */ +export type RealtimeEventType = "create" | "update" | "delete"; + +/** + * Payload received when a realtime event occurs. + */ +export interface RealtimeEvent> { + /** The type of change that occurred */ + type: RealtimeEventType; + /** The entity data (new/updated for create/update, previous for delete) */ + data: T; + /** The unique identifier of the affected entity */ + id: string; + /** ISO 8601 timestamp of when the event occurred */ + timestamp: string; + /** For update events, contains the previous data before the change */ + previousData?: T; +} + +/** + * Callback function invoked when a realtime event occurs. + */ +export type RealtimeCallback> = ( + event: RealtimeEvent +) => void; + +/** + * Options for subscribing to realtime updates. + */ +export interface SubscribeOptions { + /** Filter events by type. Defaults to all types. */ + events?: RealtimeEventType[]; +} + +/** + * Handle returned from subscribe, used to unsubscribe. + */ +export interface Subscription { + /** Stops listening to updates and cleans up the subscription. */ + unsubscribe: () => void; +} +``` + +--- + +## Extended EntityHandler Interface + +````typescript +export interface EntityHandler { + // ... existing methods (list, filter, get, create, update, delete, etc.) ... + + /** + * Subscribes to realtime updates for all records of this entity type. + * + * Receives notifications whenever any record is created, updated, or deleted. + * + * @param callback - Function called when an entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to all Task changes + * const subscription = base44.entities.Task.subscribe((event) => { + * console.log(`Task ${event.id} was ${event.type}d:`, event.data); + * }); + * + * // Later, unsubscribe + * subscription.unsubscribe(); + * ``` + * + * @example + * ```typescript + * // Subscribe only to create events + * const subscription = base44.entities.Task.subscribe( + * (event) => console.log('New task:', event.data), + * { events: ['create'] } + * ); + * ``` + */ + subscribe( + callback: RealtimeCallback, + options?: SubscribeOptions + ): Subscription; + + /** + * Subscribes to realtime updates for a specific entity record. + * + * Receives notifications when the specified record is updated or deleted. + * + * @param id - The unique identifier of the record to watch. + * @param callback - Function called when the entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to a specific task + * const subscription = base44.entities.Task.subscribe('task-123', (event) => { + * if (event.type === 'update') { + * console.log('Task updated:', event.data); + * } else if (event.type === 'delete') { + * console.log('Task was deleted'); + * } + * }); + * ``` + */ + subscribe( + id: string, + callback: RealtimeCallback, + options?: SubscribeOptions + ): Subscription; + + /** + * Subscribes to realtime updates for records matching a query. + * + * Receives notifications for records that match the specified criteria. + * Includes create events when new records match the query, update events + * when matching records change, and delete events when matching records + * are removed. + * + * @param query - Query object with field-value pairs to filter records. + * @param callback - Function called when a matching entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to all completed tasks + * const subscription = base44.entities.Task.subscribe( + * { isCompleted: true }, + * (event) => { + * console.log(`Completed task ${event.type}:`, event.data); + * } + * ); + * ``` + * + * @example + * ```typescript + * // Subscribe to high-priority active tasks + * const subscription = base44.entities.Task.subscribe( + * { priority: 'high', status: 'active' }, + * (event) => console.log('High priority task changed:', event.data) + * ); + * ``` + */ + subscribe( + query: Record, + callback: RealtimeCallback, + options?: SubscribeOptions + ): Subscription; +} +```` + +--- + +## Usage Examples + +### 1. Subscribe to ALL changes on an entity type + +```typescript +const allTasksSub = base44.entities.Task.subscribe((event) => { + console.log(`Task ${event.id} was ${event.type}d`); + console.log("Data:", event.data); +}); +``` + +### 2. Subscribe to a SPECIFIC entity instance by ID + +```typescript +const singleTaskSub = base44.entities.Task.subscribe("task-123", (event) => { + if (event.type === "update") { + console.log("Task updated:", event.data); + console.log("Previous:", event.previousData); + } else if (event.type === "delete") { + console.log("Task was deleted"); + } +}); +``` + +### 3. Subscribe to entities matching a QUERY + +```typescript +const completedTasksSub = base44.entities.Task.subscribe( + { isCompleted: true }, + (event) => { + console.log("Completed task changed:", event.type, event.data); + } +); +``` + +### 4. Filter by EVENT TYPE (only listen to creates) + +```typescript +const newTasksSub = base44.entities.Task.subscribe( + (event) => console.log("New task created:", event.data), + { events: ["create"] } +); +``` + +### 5. Combined: query + event filter + +```typescript +const newHighPrioritySub = base44.entities.Task.subscribe( + { priority: "high" }, + (event) => console.log("New high-priority task:", event.data), + { events: ["create"] } +); +``` + +### 6. Works with service role too + +```typescript +const adminSub = base44.asServiceRole.entities.User.subscribe((event) => { + console.log("User changed:", event.type, event.data); +}); +``` + +### 7. Cleanup + +```typescript +allTasksSub.unsubscribe(); +singleTaskSub.unsubscribe(); +completedTasksSub.unsubscribe(); +``` + +--- + +## Room Naming Convention (Internal) + +Based on the existing socket infrastructure, the room names follow this pattern: + +| Subscription Type | Room Name Format | +| ------------------ | ------------------------------------------------- | +| All entity changes | `entities:{appId}:{entityName}` | +| Single entity | `entities:{appId}:{entityName}:{entityId}` | +| Query-based | `entities:{appId}:{entityName}:query:{queryHash}` | + +--- + +## Design Decisions + +| Aspect | Choice | Rationale | +| ------------------------- | --------------------------------------------- | ----------------------------------------------------------- | +| **Method name** | `subscribe` | Matches Appwrite pattern, intuitive | +| **Callback position** | Callback before options (or after ID/query) | Matches SDK pattern where main data comes first | +| **Returns** | `Subscription` object with `unsubscribe()` | Clean, explicit cleanup; matches Appwrite/Supabase patterns | +| **Event payload** | Object with `type`, `data`, `id`, `timestamp` | Comprehensive info like Appwrite, typed for TypeScript | +| **Overloaded signatures** | 3 variants (all, by ID, by query) | Ergonomic API that covers all use cases | +| **Options parameter** | Optional event filtering | Extensible for future options | + +--- + +## Comparison with Similar Products + +### Appwrite + +```javascript +client.subscribe("databases.A.tables.A.rows.A", (response) => { + console.log(response.payload); +}); +``` + +- Uses channel strings for targeting +- Returns unsubscribe function directly +- Callback receives `{ events, channels, timestamp, payload }` + +### Supabase + +```typescript +const channel = supabase + .channel("room:123:messages") + .on("broadcast", { event: "message_sent" }, (payload) => { + console.log("New message:", payload); + }) + .subscribe(); +``` + +- Channel-based with chained `.on().subscribe()` pattern +- Separates channel creation from event listening + +### Meteor + +```javascript +Meteor.subscribe('roomAndMessages', roomId); + +// With observeChanges +Messages.find({ roomId }).observeChanges({ + added: (id, fields) => { ... }, + changed: (id, fields) => { ... }, + removed: (id) => { ... } +}); +``` + +- Separate `added`, `changed`, `removed` callbacks +- Cursor-based observation + +### Our Proposed API + +```typescript +base44.entities.Task.subscribe({ isCompleted: true }, (event) => { + console.log(event.type, event.data); +}); +``` + +- Matches existing SDK style (`base44.entities.EntityName.method()`) +- Single callback with event type in payload +- Overloaded for flexibility (all, by ID, by query) +- Returns subscription handle with `unsubscribe()` method From 8c7f07594cb322271d41298171b48b935a3842fe Mon Sep 17 00:00:00 2001 From: johnny Date: Mon, 29 Dec 2025 16:48:12 +0200 Subject: [PATCH 02/14] expose entities-subscribe --- src/client.ts | 12 ++- src/index.ts | 5 ++ src/modules/entities.ts | 152 +++++++++++++++++++++++++++++++++- src/modules/entities.types.ts | 142 +++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6b9448f..8e31ae1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -139,7 +139,11 @@ export function createClient(config: CreateClientConfig): Base44Client { ); const userModules = { - entities: createEntitiesModule(axiosClient, appId), + entities: createEntitiesModule({ + axios: axiosClient, + appId, + getSocket, + }), integrations: createIntegrationsModule(axiosClient, appId), auth: userAuthModule, functions: createFunctionsModule(functionsAxiosClient, appId), @@ -167,7 +171,11 @@ export function createClient(config: CreateClientConfig): Base44Client { }; const serviceRoleModules = { - entities: createEntitiesModule(serviceRoleAxiosClient, appId), + entities: createEntitiesModule({ + axios: serviceRoleAxiosClient, + appId, + getSocket, + }), integrations: createIntegrationsModule(serviceRoleAxiosClient, appId), sso: createSsoModule(serviceRoleAxiosClient, appId, token), connectors: createConnectorsModule(serviceRoleAxiosClient, appId), diff --git a/src/index.ts b/src/index.ts index 0ae6849..27159e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,11 @@ export * from "./types.js"; export type { EntitiesModule, EntityHandler, + RealtimeEventType, + RealtimeEvent, + RealtimeCallback, + SubscribeOptions, + Subscription, } from "./modules/entities.types.js"; export type { diff --git a/src/modules/entities.ts b/src/modules/entities.ts index b59a29c..f64194c 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -1,5 +1,35 @@ import { AxiosInstance } from "axios"; -import { EntitiesModule, EntityHandler } from "./entities.types"; +import { + EntitiesModule, + EntityHandler, + RealtimeCallback, + RealtimeEvent, + RealtimeEventType, + SubscribeOptions, + Subscription, +} from "./entities.types"; +import { RoomsSocket } from "../utils/socket-utils"; + +/** + * Configuration for the entities module. + * @internal + */ +export interface EntitiesModuleConfig { + axios: AxiosInstance; + appId: string; + getSocket: () => ReturnType; +} + +/** + * Creates the entities module for the Base44 SDK. + * + * @param config - Configuration object containing axios, appId, and getSocket + * @returns Entities module with dynamic entity access + * @internal + */ +export function createEntitiesModule( + config: EntitiesModuleConfig +): EntitiesModule; /** * Creates the entities module for the Base44 SDK. @@ -8,11 +38,32 @@ import { EntitiesModule, EntityHandler } from "./entities.types"; * @param appId - Application ID * @returns Entities module with dynamic entity access * @internal + * @deprecated Use the config object overload instead */ export function createEntitiesModule( axios: AxiosInstance, appId: string +): EntitiesModule; + +export function createEntitiesModule( + configOrAxios: EntitiesModuleConfig | AxiosInstance, + appIdArg?: string ): EntitiesModule { + // Handle both old and new signatures for backwards compatibility + const config: EntitiesModuleConfig = + "axios" in configOrAxios + ? configOrAxios + : { + axios: configOrAxios, + appId: appIdArg!, + getSocket: () => { + throw new Error( + "Realtime subscriptions are not available. Please update your client configuration." + ); + }, + }; + + const { axios, appId, getSocket } = config; // Using Proxy to dynamically handle entity names return new Proxy( {}, @@ -28,25 +79,65 @@ export function createEntitiesModule( } // Create entity handler - return createEntityHandler(axios, appId, entityName); + return createEntityHandler(axios, appId, entityName, getSocket); }, } ) as EntitiesModule; } +/** + * Creates a stable hash from a query object for room naming. + * @internal + */ +function hashQuery(query: Record): string { + const sortedKeys = Object.keys(query).sort(); + const normalized = sortedKeys + .map((k) => `${k}:${JSON.stringify(query[k])}`) + .join("|"); + // Simple hash function + let hash = 0; + for (let i = 0; i < normalized.length; i++) { + const char = normalized.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(36); +} + +/** + * Parses the realtime message data and extracts event information. + * @internal + */ +function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { + try { + const parsed = JSON.parse(dataStr); + return { + type: parsed.type as RealtimeEventType, + data: parsed.data, + id: parsed.id || parsed.data?.id, + timestamp: parsed.timestamp || new Date().toISOString(), + previousData: parsed.previousData, + }; + } catch { + return null; + } +} + /** * Creates a handler for a specific entity. * * @param axios - Axios instance * @param appId - Application ID * @param entityName - Entity name + * @param getSocket - Function to get the socket instance * @returns Entity handler with CRUD methods * @internal */ function createEntityHandler( axios: AxiosInstance, appId: string, - entityName: string + entityName: string, + getSocket: () => ReturnType ): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; @@ -125,5 +216,60 @@ function createEntityHandler( }, }); }, + + // Subscribe to realtime updates + subscribe( + callbackOrIdOrQuery: RealtimeCallback | string | Record, + callbackOrOptions?: RealtimeCallback | SubscribeOptions, + optionsArg?: SubscribeOptions + ): Subscription { + let room: string; + let callback: RealtimeCallback; + let options: SubscribeOptions | undefined; + + // Parse overloaded arguments + if (typeof callbackOrIdOrQuery === "function") { + // subscribe(callback, options?) + room = `entities:${appId}:${entityName}`; + callback = callbackOrIdOrQuery as RealtimeCallback; + options = callbackOrOptions as SubscribeOptions | undefined; + } else if (typeof callbackOrIdOrQuery === "string") { + // subscribe(id, callback, options?) + room = `entities:${appId}:${entityName}:${callbackOrIdOrQuery}`; + callback = callbackOrOptions as RealtimeCallback; + options = optionsArg; + } else { + // subscribe(query, callback, options?) + const queryHash = hashQuery(callbackOrIdOrQuery); + room = `entities:${appId}:${entityName}:query:${queryHash}`; + callback = callbackOrOptions as RealtimeCallback; + options = optionsArg; + } + + const eventFilter = options?.events; + + // Get the socket and subscribe to the room + const socket = getSocket(); + const unsubscribe = socket.subscribeToRoom(room, { + update_model: (msg) => { + // Only process messages for our room + if (msg.room !== room) return; + + const event = parseRealtimeMessage(msg.data); + if (!event) return; + + // Apply event type filter if specified + if (eventFilter && !eventFilter.includes(event.type)) { + return; + } + + callback(event); + }, + }); + + return { + unsubscribe, + }; + }, }; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 712cf0f..8b99d68 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -1,3 +1,47 @@ +/** + * Event types for realtime entity updates. + */ +export type RealtimeEventType = "create" | "update" | "delete"; + +/** + * Payload received when a realtime event occurs. + */ +export interface RealtimeEvent> { + /** The type of change that occurred */ + type: RealtimeEventType; + /** The entity data (new/updated for create/update, previous for delete) */ + data: T; + /** The unique identifier of the affected entity */ + id: string; + /** ISO 8601 timestamp of when the event occurred */ + timestamp: string; + /** For update events, contains the previous data before the change */ + previousData?: T; +} + +/** + * Callback function invoked when a realtime event occurs. + */ +export type RealtimeCallback> = ( + event: RealtimeEvent +) => void; + +/** + * Options for subscribing to realtime updates. + */ +export interface SubscribeOptions { + /** Filter events by type. Defaults to all types. */ + events?: RealtimeEventType[]; +} + +/** + * Handle returned from subscribe, used to unsubscribe. + */ +export interface Subscription { + /** Stops listening to updates and cleans up the subscription. */ + unsubscribe: () => void; +} + /** * Entity handler providing CRUD operations for a specific entity type. * @@ -261,6 +305,104 @@ export interface EntityHandler { * ``` */ importEntities(file: File): Promise; + + /** + * Subscribes to realtime updates for all records of this entity type. + * + * Receives notifications whenever any record is created, updated, or deleted. + * + * @param callback - Function called when an entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to all Task changes + * const subscription = base44.entities.Task.subscribe((event) => { + * console.log(`Task ${event.id} was ${event.type}d:`, event.data); + * }); + * + * // Later, unsubscribe + * subscription.unsubscribe(); + * ``` + * + * @example + * ```typescript + * // Subscribe only to create events + * const subscription = base44.entities.Task.subscribe( + * (event) => console.log("New task:", event.data), + * { events: ["create"] } + * ); + * ``` + */ + subscribe(callback: RealtimeCallback, options?: SubscribeOptions): Subscription; + + /** + * Subscribes to realtime updates for a specific entity record. + * + * Receives notifications when the specified record is updated or deleted. + * + * @param id - The unique identifier of the record to watch. + * @param callback - Function called when the entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to a specific task + * const subscription = base44.entities.Task.subscribe("task-123", (event) => { + * if (event.type === "update") { + * console.log("Task updated:", event.data); + * } else if (event.type === "delete") { + * console.log("Task was deleted"); + * } + * }); + * ``` + */ + subscribe( + id: string, + callback: RealtimeCallback, + options?: SubscribeOptions + ): Subscription; + + /** + * Subscribes to realtime updates for records matching a query. + * + * Receives notifications for records that match the specified criteria. + * Includes create events when new records match the query, update events + * when matching records change, and delete events when matching records + * are removed. + * + * @param query - Query object with field-value pairs to filter records. + * @param callback - Function called when a matching entity changes. + * @param options - Optional configuration for filtering events. + * @returns Subscription handle with an unsubscribe method. + * + * @example + * ```typescript + * // Subscribe to all completed tasks + * const subscription = base44.entities.Task.subscribe( + * { isCompleted: true }, + * (event) => { + * console.log(`Completed task ${event.type}:`, event.data); + * } + * ); + * ``` + * + * @example + * ```typescript + * // Subscribe to high-priority active tasks + * const subscription = base44.entities.Task.subscribe( + * { priority: "high", status: "active" }, + * (event) => console.log("High priority task changed:", event.data) + * ); + * ``` + */ + subscribe( + query: Record, + callback: RealtimeCallback, + options?: SubscribeOptions + ): Subscription; } /** From 497794c987604d915c7d80e6b84fc81bbb23162c Mon Sep 17 00:00:00 2001 From: johnny Date: Sun, 4 Jan 2026 15:26:15 +0200 Subject: [PATCH 03/14] bye md --- comparison/appwrite.md | 557 --------------------- comparison/meteor.md | 1024 -------------------------------------- comparison/supabase.md | 709 -------------------------- realtime-api-proposal.md | 330 ------------ 4 files changed, 2620 deletions(-) delete mode 100644 comparison/appwrite.md delete mode 100644 comparison/meteor.md delete mode 100644 comparison/supabase.md delete mode 100644 realtime-api-proposal.md diff --git a/comparison/appwrite.md b/comparison/appwrite.md deleted file mode 100644 index 1f328d3..0000000 --- a/comparison/appwrite.md +++ /dev/null @@ -1,557 +0,0 @@ ---- -layout: article -title: Realtime -description: Want to build dynamic and interactive applications with real-time data updates? Appwrite Realtime API makes it possible, get started with our intro guide. ---- - -Appwrite supports multiple protocols for accessing the server, including [REST](/docs/apis/rest), [GraphQL](/docs/apis/graphql), and [Realtime](/docs/apis/realtime). The Appwrite Realtime allows you to listen to any Appwrite events in realtime using the `subscribe` method. - -Instead of requesting new data via HTTP, the subscription will receive new data every time it changes, any connected client receives that update within milliseconds via a WebSocket connection. - -This lets you build an interactive and responsive user experience by providing information from all of Appwrite's services in realtime. The example below shows subscribing to realtime events for file uploads. - -{% multicode %} - -```client-web -import { Client } from "appwrite"; - -const client = new Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -// Subscribe to files channel -client.subscribe('files', response => { - if(response.events.includes('buckets.*.files.*.create')) { - // Log when a new file is uploaded - console.log(response.payload); - } -}); -``` - -```client-flutter -import 'package:appwrite/appwrite.dart'; - -final client = Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -final realtime = Realtime(client); - -// Subscribe to files channel -final subscription = realtime.subscribe(['files']); - -subscription.stream.listen((response) { - if(response.events.contains('buckets.*.files.*.create')) { - // Log when a new file is uploaded - print(response.payload); - } -}); -``` - -```client-apple -import Appwrite -import AppwriteModels - -let client = Client() - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -let realtime = Realtime(client) - -// Subscribe to files channel -let subscription = realtime.subscribe(channels: ["files"]) { response in - if (message.events!.contains("buckets.*.files.*.create")) { - // Log when a new file is uploaded - print(String(describing: response)) - } -} -``` - -```client-android-kotlin -import io.appwrite.Client -import io.appwrite.services.Realtime - -val client = Client(context) - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -val realtime = Realtime(client) - -// Subscribe to files channel -let subscription = realtime.subscribe("files") { - if(it.events.contains("buckets.*.files.*.create")) { - // Log when a new file is uploaded - print(it.payload.toString()); - } -} -``` - -{% /multicode %} - -To subscribe to updates from different Appwrite resources, you need to specify one or more [channels](/docs/apis/realtime#channels). The channels offer a wide and powerful selection that will allow you to listen to all possible resources. This allows you to receive updates not only from the database, but from _all_ the services that Appwrite offers. - -If you subscribe to a channel, you will receive callbacks for a variety of events related to the channel. The events column in the callback can be used to filter and respond to specific events in a channel. - -[View a list of all available events](/docs/advanced/platform/events). - -{% info title="Permissions" %} -All subscriptions are secured by the [permissions system](/docs/advanced/platform/permissions) offered by Appwrite, meaning a user will only receive updates to resources they have permission to access. - -Using `Role.any()` on read permissions will allow any client to receive updates. -{% /info %} - -# Authentication {% #authentication %} - -Realtime authenticates using an existing user session. If you authenticate **after** creating a subscription, the subscription will not receive updates for the newly authenticated user. You will need to re-create the subscription to work with the new user. - -More information and examples of authenticating users can be found in the dedicated [authentication docs](/docs/products/auth). - -# Examples {% #examples %} - -The examples below will show you how you can use Realtime in various ways. - -## Subscribe to a Channel {% #subscribe-to-a-channel %} - -In this example we are subscribing to all updates related to our account by using the `account` channel. This will be triggered by any update related to the authenticated user, like updating the user's name or e-mail address. - -{% multicode %} - -```client-web -import { Client } from "appwrite"; - -const client = new Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -client.subscribe('account', response => { - // Callback will be executed on all account events. - console.log(response); -}); -``` - -```client-flutter -import 'package:appwrite/appwrite.dart'; - -final client = Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -final realtime = Realtime(client); - -final subscription = realtime.subscribe(['account']); - -subscription.stream.listen((response) { - // Callback will be executed on all account events. - print(response); -}) -``` - -```client-apple -import Appwrite -import AppwriteModels - -let client = Client() - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -let realtime = Realtime(client) - -let subscription = realtime.subscribe(channel: "account", callback: { response in - // Callback will be executed on all account events. - print(String(describing: response)) -}) -``` - -```client-android-kotlin -import io.appwrite.Client -import io.appwrite.services.Realtime - -val client = Client(context) - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -val realtime = Realtime(client) - -val subscription = realtime.subscribe("account") { - // Callback will be executed on all account events. - print(it.payload.toString()) -} -``` - -{% /multicode %} - -## Subscribe to Multiple Channels {% #subscribe-to-multiple-channel %} - -You can also listen to multiple channels at once by passing an array of channels. This will trigger the callback for any events for all channels passed. - -In this example we are listening to the row A and all files by subscribing to the `databases.A.tables.A.rows.A` and `files` channels. - -{% multicode %} - -```client-web -import { Client } from "appwrite"; - -const client = new Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -client.subscribe(['tables.A.rows.A', 'files'], response => { - // Callback will be executed on changes for rows A and all files. - console.log(response); -}); -``` - -```client-flutter -import 'package:appwrite/appwrite.dart'; - -final client = Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -final realtime = Realtime(client); - -final subscription = realtime.subscribe(['databases.A.tables.A.rows.A', 'files']); - -subscription.stream.listen((response) { - // Callback will be executed on changes for rows A and all files. - print(response); -}) -``` - -```client-apple -import Appwrite -import AppwriteModels - -let client = Client() - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -let realtime = Realtime(client) - -realtime.subscribe(channels: ["databases.A.tables.A.rows.A", "files"]) { response in - // Callback will be executed on changes for rows A and all files. - print(String(describing: response)) -} -``` - -```client-android-kotlin -import io.appwrite.Client -import io.appwrite.services.Realtime - -val client = Client(context) - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") -val realtime = Realtime(client) - -realtime.subscribe("databases.A.tables.A.rows.A", "files") { - // Callback will be executed on changes for rows A and all files. - print(it.toString()) -} -``` - -{% /multicode %} - -## Unsubscribe {% #unsubscribe %} - -If you no longer want to receive updates from a subscription, you can unsubscribe so that your callbacks are no longer called. Leaving old subscriptions alive and resubscribing can result in duplicate subscriptions and cause race conditions. - -{% multicode %} - -```client-web -import { Client } from "appwrite"; - -const client = new Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -const unsubscribe = client.subscribe('files', response => { - // Callback will be executed on changes for all files. - console.log(response); -}); - -// Closes the subscription. -unsubscribe(); -``` - -```client-flutter -import 'package:appwrite/appwrite.dart'; - -final client = Client() - .setEndpoint('https://.cloud.appwrite.io/v1') - .setProject(''); - -final realtime = Realtime(client); - -final subscription = realtime.subscribe(['files']); - -subscription.stream.listen((response) { - // Callback will be executed on changes for all files. - print(response); -}) - -// Closes the subscription. -subscription.close(); -``` - -```client-apple -import Appwrite - -let client = Client() -let realtime = Realtime(client) - -let subscription = realtime.subscribe(channel: "files") { response in - // Callback will be executed on changes for all files. - print(response.toString()) -} - -// Closes the subscription. -subscription.close() -``` - -```client-android-kotlin -import io.appwrite.Client -import io.appwrite.services.Realtime - -val client = Client(context) - .setEndpoint("https://.cloud.appwrite.io/v1") - .setProject("") - -val realtime = Realtime(client) - -val subscription = realtime.subscribe("files") { - // Callback will be executed on changes for all files. - print(it.toString()) -} - -// Closes the subscription. -subscription.close() -``` - -{% /multicode %} - -# Payload {% #payload %} - -The payload from the subscription will contain following properties: - -{% table %} - -- Name -- Type -- Description - ---- - -- events -- string[] -- The [Appwrite events](/docs/advanced/platform/events) that triggered this update. - ---- - -- channels -- string[] -- An array of [channels](/docs/apis/realtime#channels) that can receive this message. - ---- - -- timestamp -- string -- The [ISO 8601 timestamp](https://en.wikipedia.org/wiki/ISO_8601) in UTC timezone from the server - ---- - -- payload -- object -- Payload contains the data equal to the response model. - {% /table %} - -If you subscribe to the `rows` channel and a row the user is allowed to read is updated, you will receive an object containing information about the event and the updated row. - -The response will look like this: - -```json -{ - "events": [ - "databases.default.tables.sample.rows.63c98b9baea0938e1206.update", - "databases.*.tables.*.rows.*.update", - "databases.default.tables.*.rows.63c98b9baea0938e1206.update", - "databases.*.tables.*.rows.63c98b9baea0938e1206.update", - "databases.*.tables.sample.rows.63c98b9baea0938e1206.update", - "databases.default.tables.sample.rows.*.update", - "databases.*.tables.sample.rows.*.update", - "databases.default.tables.*.rows.*.update", - "databases.default.tables.sample.rows.63c98b9baea0938e1206", - "databases.*.tables.*.rows.*", - "databases.default.tables.*.rows.63c98b9baea0938e1206", - "databases.*.tables.*.rows.63c98b9baea0938e1206", - "databases.*.tables.sample.rows.63c98b9baea0938e1206", - "databases.default.tables.sample.rows.*", - "databases.*.tables.sample.rows.*", - "databases.default.tables.*.rows.*", - "databases.default.tables.sample", - "databases.*.tables.*", - "databases.default.tables.*", - "databases.*.tables.sample", - "databases.default", - "databases.*" - ], - "channels": [ - "rows", - "databases.default.tables.sample.rows", - "databases.default.tables.sample.rows.63c98b9baea0938e1206" - ], - "timestamp": "2023-01-19 18:30:04.051", - "payload": { - "ip": "127.0.0.1", - "stringArray": ["sss"], - "email": "joe@example.com", - "stringRequired": "req", - "float": 3.3, - "boolean": false, - "integer": 3, - "enum": "apple", - "stringDefault": "default", - "datetime": "2023-01-19T10:27:09.428+00:00", - "url": "https://appwrite.io", - "$id": "63c98b9baea0938e1206", - "$createdAt": "2023-01-19T18:27:39.715+00:00", - "$updatedAt": "2023-01-19T18:30:04.040+00:00", - "$permissions": [], - "$tableId": "sample", - "$databaseId": "default" - } -} -``` - -# Channels {% #channels %} - -A list of all channels available you can subscribe to. IDs cannot be wildcards. - -{% table %} - -- Channel -- Description - ---- - -- `account` -- All account related events (session create, name update...) - ---- - -- `databases..tables..rows` -- Any create/update/delete events to any row in a table - ---- - -- `rows` -- Any create/update/delete events to any row - ---- - -- `databases..tables..rows.` -- Any update/delete events to a given row - ---- - -- `files` -- Any create/update/delete events to any file - ---- - -- `buckets..files.` -- Any update/delete events to a given file of the given bucket - ---- - -- `buckets..files` -- Any update/delete events to any file of the given bucket - ---- - -- `teams` -- Any create/update/delete events to a any team - ---- - -- `teams.` -- Any update/delete events to a given team - ---- - -- `memberships` -- Any create/update/delete events to a any membership - ---- - -- `memberships.` -- Any update/delete events to a given membership - ---- - -- `executions` -- Any update to executions - ---- - -- `executions.` -- Any update to a given execution - ---- - -- `functions.` -- Any execution event to a given function - -{% /table %} - -# Custom endpoint {% #custom-endpoint %} - -The SDK will guess the endpoint of the Realtime API when setting the endpoint of your Appwrite instance. If you are running Appwrite with a custom proxy and changed the route of the Realtime API, you can call the `setEndpointRealtime` method on the Client SDK and set your new endpoint value. - -By default the endpoint is `wss://.cloud.appwrite.io/v1/realtime`. - -{% multicode %} - -```client-web -import { Client } from "appwrite"; -const client = new Client(); - -client.setEndpointRealtime('wss://.cloud.appwrite.io/v1/realtime'); -``` - -```client-flutter -import 'package:appwrite/appwrite.dart'; - -final client = Client(); -client.setEndpointRealtime('wss://.cloud.appwrite.io/v1/realtime'); -``` - -```client-apple -import Appwrite - -let client = Client() -client.setEndpointRealtime("wss://.cloud.appwrite.io/v1/realtime") -``` - -```client-android-kotlin -import io.appwrite.Client - -val client = Client(context) -client.setEndpointRealtime("wss://.cloud.appwrite.io/v1/realtime") -``` - -{% /multicode %} - -# Limitations {% #limitations %} - -While the Realtime API offers robust capabilities, there are currently some limitations to be aware of in its implementation. - -## Subscription changes {% #subscription-changes %} - -The SDK creates a single WebSocket connection for all subscribed channels. -Each time a channel is added or unsubscribed, the SDK currently creates a completely new connection and terminates the old one. -Therefore, subscriptions to channels should always be done in conjunction with state management so as not to be unnecessarily -built up several times by multiple components' life cycles. - -## Server SDKs {% #server-sdks %} - -We currently are not offering access to realtime with Server SDKs and an API key. diff --git a/comparison/meteor.md b/comparison/meteor.md deleted file mode 100644 index 01d7010..0000000 --- a/comparison/meteor.md +++ /dev/null @@ -1,1024 +0,0 @@ -# Meteor API - -Meteor global object has many functions and properties for handling utilities, network and much more. - -### Core APIs {#core} - - - -On a server, the function will run as soon as the server process is -finished starting. On a client, the function will run as soon as the DOM -is ready. Code wrapped in `Meteor.startup` always runs after all app -files have loaded, so you should put code here if you want to access -shared variables from other files. - -The `startup` callbacks are called in the same order as the calls to -`Meteor.startup` were made. - -On a client, `startup` callbacks from packages will be called -first, followed by `` templates from your `.html` files, -followed by your application code. - -::: code-group - -```js [server.js] -import { Meteor } from "meteor/meteor"; -import { LinksCollection } from "/imports/api/links"; - -Meteor.startup(async () => { - // If the Links collection is empty, add some data. - if ((await LinksCollection.find().countAsync()) === 0) { - await LinksCollection.insertAsync({ - title: "Do the Tutorial", - url: "https://docs.meteor.com/tutorials/react", - }); - } -}); -``` - -```js [client.js] -import React from "react"; -import { createRoot } from "react-dom/client"; -import { Meteor } from "meteor/meteor"; -import { App } from "/imports/ui/App"; - -// Setup react root -Meteor.startup(() => { - const container = document.getElementById("react-target"); - const root = createRoot(container); - root.render(); -}); -``` - -::: - - - - - - - - - - - -::: danger -`Meteor.isServer` can be used to limit where code runs, but it does not prevent code from -being sent to the client. Any sensitive code that you don’t want served to the client, -such as code containing passwords or authentication mechanisms, -should be kept in the `server` directory. -::: - - - - - - - - - - - -### Method APIs {#methods} - -Meteor Methods are Remote Procedure Calls (RPCs) are functions defined by `Meteor.methods` -and called by [`Meteor.call`](#Meteor-call). - - - -The most basic way to define a method is to provide a function: - -::: code-group - -```js [server.js] -import { Meteor } from "meteor/meteor"; - -Meteor.methods({ - sum(a, b) { - return a + b; - }, -}); -``` - -```js [client.js] -import { Meteor } from "meteor/meteor"; - -const result = await Meteor.callAsync("sum", 1, 2); -console.log(result); // 3 -``` - -::: - -You can use `Meteor.methods` to define multiple methods at once. - -You can think of `Meteor.methods` as a way of defining a remote object that is your server API. - -A more complete example: - -::: code-group - -```js [server.js] -import { Meteor } from "meteor/meteor"; -import { check } from "meteor/check"; -import { LinksCollection } from "/imports/api/links"; - -Meteor.methods({ - async addLink(link) { - check(link, String); // check if the link is a string - - // Do stuff... - const linkID = await LinksCollection.insertAsync(link); - if (/* you want to throw an error */) { - throw new Meteor.Error('Something is wrong', "Some details"); - } - - return linkID; - }, - - bar() { - // Do other stuff... - return 'baz'; - } -}); -``` - -```js [client.js] -import React from "react"; -import { Meteor } from "meteor/meteor"; - -function Component() { - const addLink = () => - Meteor.callAsync("addLink", "https://docs.meteor.com/tutorials/react/"); - - return ( -
- -
- ); -} -``` - -::: - -Calling `methods` on the server defines functions that can be called remotely by -clients. They should return an [EJSON](./EJSON)-able value or throw an -exception. Inside your method invocation, `this` is bound to a method -invocation object, which provides the following: - -- `isSimulation`: a boolean value, true if this invocation is a stub. -- `unblock`: when called, allows the next method from this client to - begin running. -- `userId`: the id of the current user. -- `setUserId`: a function that associates the current client with a user. -- `connection`: on the server, the [connection](#Meteor-onConnection) this method call was received on. - -Calling `methods` on the client defines _stub_ functions associated with -server methods of the same name. You don't have to define a stub for -your method if you don't want to. In that case, method calls are just -like remote procedure calls in other systems, and you'll have to wait -for the results from the server. - -If you do define a stub, when a client invokes a server method it will -also run its stub in parallel. On the client, the return value of a -stub is ignored. Stubs are run for their side-effects: they are -intended to _simulate_ the result of what the server's method will do, -but without waiting for the round trip delay. If a stub throws an -exception it will be logged to the console. - -You use methods all the time, because the database mutators -([`insert`](./collections#Mongo-Collection-insert), [`update`](./collections#Mongo-Collection-update), [`remove`](./collections#Mongo-Collection-remove)) are implemented -as methods. When you call any of these functions on the client, you're invoking -their stub version that update the local cache, and sending the same write -request to the server. When the server responds, the client updates the local -cache with the writes that actually occurred on the server. - -You don't have to put all your method definitions into a single `Meteor.methods` -call; you may call it multiple times, as long as each method has a unique name. - -If a client calls a method and is disconnected before it receives a response, -it will re-call the method when it reconnects. This means that a client may -call a method multiple times when it only means to call it once. If this -behavior is problematic for your method, consider attaching a unique ID -to each method call on the client, and checking on the server whether a call -with this ID has already been made. Alternatively, you can use -[`Meteor.apply`](#Meteor-apply) with the noRetry option set to true. - -Read more about methods and how to use them in the [Methods](http://guide.meteor.com/methods.html) article in the Meteor Guide. - - - -This method can be used to determine if the current method invocation is -asynchronous. It returns true if the method is running on the server and came from -an async call(`Meteor.callAsync`) - -::: code-group - -```js [server.js] -import { Meteor } from "meteor/meteor"; - -Meteor.methods({ - async foo() { - return Meteor.isAsyncCall(); - }, -}); -``` - -```js [client.js] -import { Meteor } from "meteor/meteor"; - -const result = await Meteor.callAsync("foo"); -console.log(result); // true - -Meteor.call("foo", (err, result) => { - console.log(result); // false -}); -``` - -::: - -## this.userId {#methods-userId} - -The user id is an arbitrary string — typically the id of the user record -in the database. You can set it with the `setUserId` function. If you're using -the [Meteor accounts system](./accounts.md) then this is handled for you. - -```js -import { Meteor } from "meteor/meteor"; - -Meteor.methods({ - foo() { - console.log(this.userId); - }, -}); -``` - -## this.setUserId {#methods-setUserId} - -Call this function to change the currently logged-in user on the -connection that made this method call. This simply sets the value of -`userId` for future method calls received on this connection. Pass -`null` to log out the connection. - -If you are using the [built-in Meteor accounts system](./accounts) then this -should correspond to the `_id` field of a document in the -[`Meteor.users`](./accounts.md#Meteor-user) collection. - -`setUserId` is not retroactive. It affects the current method call and -any future method calls on the connection. Any previous method calls on -this connection will still see the value of `userId` that was in effect -when they started. - -If you also want to change the logged-in user on the client, then after calling -`setUserId` on the server, call `Meteor.connection.setUserId(userId)` on the -client. - -```js -import { Meteor } from "meteor/meteor"; - -Meteor.methods({ - foo() { - this.setUserId("some-id"); - }, -}); -``` - -## this.connection {#methods-connection} - -Access inside a method invocation. The [connection](#Meteor-onConnection) that this method was received on. -null if the method is not associated with a connection, -eg. a server initiated method call. Calls to methods -made from a server method which was in turn initiated from the client share the same -connection. - - - -For example: - -::: code-group - -```js [server.js] -import { Meteor } from "meteor/meteor"; -// on the server, pick a code unique to this error -// the reason field should be a useful debug message -Meteor.methods({ - methodName() { - throw new Meteor.Error( - "logged-out", - "The user must be logged in to post a comment." - ); - }, -}); -``` - -```js [client.js] -import { Meteor } from "meteor/meteor"; -// on the client -Meteor.call("methodName", function (error) { - // identify the error - if (error && error.error === "logged-out") { - // show a nice error message - Session.set("errorMessage", "Please log in to post a comment."); - } -}); -``` - -::: - -If you want to return an error from a method, throw an exception. Methods can -throw any kind of exception. But `Meteor.Error` is the only kind of error that -a server will send to the client. If a method function throws a different -exception, then it will be mapped to a sanitized version on the -wire. Specifically, if the `sanitizedError` field on the thrown error is set to -a `Meteor.Error`, then that error will be sent to the client. Otherwise, if no -sanitized version is available, the client gets -`Meteor.Error(500, 'Internal server error')`. - - - -This is how to invoke a method with a sync stub. It will run the method on the server. If a -stub is available, it will also run the stub on the client. (See also -[`Meteor.apply`](#Meteor-apply), which is identical to `Meteor.call` except that -you specify the parameters as an array instead of as separate arguments and you -can specify a few options controlling how the method is executed.) - -If you include a callback function as the last argument (which can't be -an argument to the method, since functions aren't serializable), the -method will run asynchronously: it will return nothing in particular and -will not throw an exception. When the method is complete (which may or -may not happen before `Meteor.call` returns), the callback will be -called with two arguments: `error` and `result`. If an error was thrown, -then `error` will be the exception object. Otherwise, `error` will be -`undefined` and the return value (possibly `undefined`) will be in `result`. - -```js -// Asynchronous call -Meteor.call('foo', 1, 2, (error, result) => { ... }); -``` - -If you do not pass a callback on the server, the method invocation will -block until the method is complete. It will eventually return the -return value of the method, or it will throw an exception if the method -threw an exception. (Possibly mapped to 500 Server Error if the -exception happened remotely and it was not a `Meteor.Error` exception.) - -```js -// Synchronous call -const result = Meteor.call("foo", 1, 2); -``` - -On the client, if you do not pass a callback and you are not inside a -stub, `call` will return `undefined`, and you will have no way to get -the return value of the method. That is because the client doesn't have -fibers, so there is not actually any way it can block on the remote -execution of a method. - -Finally, if you are inside a stub on the client and call another -method, the other method is not executed (no RPC is generated, nothing -"real" happens). If that other method has a stub, that stub stands in -for the method and is executed. The method call's return value is the -return value of the stub function. The client has no problem executing -a stub synchronously, and that is why it's okay for the client to use -the synchronous `Meteor.call` form from inside a method body, as -described earlier. - -Meteor tracks the database writes performed by methods, both on the client and -the server, and does not invoke `asyncCallback` until all of the server's writes -replace the stub's writes in the local cache. In some cases, there can be a lag -between the method's return value being available and the writes being visible: -for example, if another method still outstanding wrote to the same document, the -local cache may not be up to date until the other method finishes as well. If -you want to process the method's result as soon as it arrives from the server, -even if the method's writes are not available yet, you can specify an -`onResultReceived` callback to [`Meteor.apply`](#Meteor-apply). - -::: warning -Use `Meteor.call` only to call methods that do not have a stub, or have a sync stub. If you want to call methods with an async stub, `Meteor.callAsync` can be used with any method. -::: - - - -`Meteor.callAsync` is just like `Meteor.call`, except that it'll return a promise that you need to solve to get the server result. Along with the promise returned by `callAsync`, you can also handle `stubPromise` and `serverPromise` for managing client-side simulation and server response. - -The following sections guide you in understanding these promises and how to manage them effectively. - -#### serverPromise - -```javascript -try { - await Meteor.callAsync("greetUser", "John"); - // 🟢 Server ended with success -} catch (e) { - console.error("Error:", error.reason); // 🔴 Server ended with error -} - -Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available -``` - -#### stubPromise - -```javascript -await Meteor.callAsync("greetUser", "John").stubPromise; - -// 🔵 Client simulation -Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) -``` - -#### stubPromise and serverPromise - -```javascript -const { stubPromise, serverPromise } = Meteor.callAsync("greetUser", "John"); - -await stubPromise; - -// 🔵 Client simulation -Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) - -try { - await serverPromise; - // 🟢 Server ended with success -} catch (e) { - console.error("Error:", error.reason); // 🔴 Server ended with error -} - -Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available -``` - -#### Meteor 2.x contrast - -For those familiar with legacy Meteor 2.x, the handling of client simulation and server response was managed using fibers, as explained in the following section. This comparison illustrates how async inclusion with standard promises has transformed the way Meteor operates in modern versions. - -```javascript -Meteor.call("greetUser", "John", function (error, result) { - if (error) { - console.error("Error:", error.reason); // 🔴 Server ended with error - } else { - console.log("Result:", result); // 🟢 Server ended with success - } - - Greetings.findOne({ name: "John" }); // 🗑️ Data is NOT available -}); - -// 🔵 Client simulation -Greetings.findOne({ name: "John" }); // 🧾 Data is available (Optimistic-UI) -``` - - - -`Meteor.apply` is just like `Meteor.call`, except that the method arguments are -passed as an array rather than directly as arguments, and you can specify -options about how the client executes the method. - -::: warning -Use `Meteor.apply` only to call methods that do not have a stub, or have a sync stub. If you want to call methods with an async stub, `Meteor.applyAsync` can be used with any method. -::: - - - -`Meteor.applyAsync` is just like `Meteor.apply`, except it is an async function, and it will consider that the stub is async. - -### Publish and subscribe {#pubsub} - -These functions control how Meteor servers publish sets of records and -how clients can subscribe to those sets. - - -To publish records to clients, call `Meteor.publish` on the server with -two parameters: the name of the record set, and a _publish function_ -that Meteor will call each time a client subscribes to the name. - -Publish functions can return a -[`Collection.Cursor`](./collections.md#mongo_cursor), in which case Meteor -will publish that cursor's documents to each subscribed client. You can -also return an array of `Collection.Cursor`s, in which case Meteor will -publish all of the cursors. - -::: warning -If you return multiple cursors in an array, they currently must all be from -different collections. We hope to lift this restriction in a future release. -::: - - - -```js -import { Meteor } from "meteor/meteor"; -import { check } from "meteor/check"; -import { Rooms } from "/imports/api/Rooms"; -import { Messages } from "/imports/api/Messages"; - -// Server: Publish the `Rooms` collection, minus secret info... -Meteor.publish("rooms", function () { - return Rooms.find( - {}, - { - fields: { secretInfo: 0 }, - } - ); -}); - -// ...and publish secret info for rooms where the logged-in user is an admin. If -// the client subscribes to both publications, the records are merged together -// into the same documents in the `Rooms` collection. Note that currently object -// values are not recursively merged, so the fields that differ must be top -// level fields. -Meteor.publish("adminSecretInfo", function () { - return Rooms.find( - { admin: this.userId }, - { - fields: { secretInfo: 1 }, - } - ); -}); - -// Publish dependent documents and simulate joins. -Meteor.publish("roomAndMessages", function (roomId) { - check(roomId, String); - - return [ - Rooms.find( - { _id: roomId }, - { - fields: { secretInfo: 0 }, - } - ), - Messages.find({ roomId }), - ]; -}); -``` - -Alternatively, a publish function can directly control its published record set -by calling the functions [`added`](#Subscription-added) (to add a new document to the -published record set), [`changed`](#Subscription-changed) (to change or clear some -fields on a document already in the published record set), and -[`removed`](#Subscription-removed) (to remove documents from the published record -set). These methods are provided by `this` in your publish function. - -If a publish function does not return a cursor or array of cursors, it is -assumed to be using the low-level `added`/`changed`/`removed` interface, and it -**must also call [`ready`](#Subscription-ready) once the initial record set is -complete**. - -::: code-group - -```js [collections.js] -import { Mongo } from "meteor/mongo"; - -export const Rooms = new Mongo.Collection("rooms"); -export const SecretData = new Mongo.Collection("messages"); -``` - -```js [server.js] -import { Meteor } from "meteor/meteor"; -import { check } from "meteor/check"; -import { Rooms, SecretData } from "/imports/api/collections"; - -// Publish the current size of a collection. -Meteor.publish("countsByRoom", function (roomId) { - check(roomId, String); - - let count = 0; - let initializing = true; - - // `observeChanges` only returns after the initial `added` callbacks have run. - // Until then, we don't want to send a lot of `changed` messages—hence - // tracking the `initializing` state. - const handle = Messages.find({ roomId }).observeChanges({ - added: (id) => { - count += 1; - - if (!initializing) { - this.changed("counts", roomId, { count }); - } - }, - - removed: (id) => { - count -= 1; - this.changed("counts", roomId, { count }); - }, - - // We don't care about `changed` events. - }); - - // Instead, we'll send one `added` message right after `observeChanges` has - // returned, and mark the subscription as ready. - initializing = false; - this.added("counts", roomId, { count }); - this.ready(); - - // Stop observing the cursor when the client unsubscribes. Stopping a - // subscription automatically takes care of sending the client any `removed` - // messages. - this.onStop(() => handle.stop()); -}); - -// Sometimes publish a query, sometimes publish nothing. -Meteor.publish("secretData", function () { - if (this.userId === "superuser") { - return SecretData.find(); - } else { - // Declare that no data is being published. If you leave this line out, - // Meteor will never consider the subscription ready because it thinks - // you're using the `added/changed/removed` interface where you have to - // explicitly call `this.ready`. - return []; - } -}); -``` - -```js [client.js] -import { Meteor } from "meteor/meteor"; -import { Mongo } from "meteor/mongo"; -import { Session } from "meteor/session"; -// Declare a collection to hold the count object. -const Counts = new Mongo.Collection("counts"); - -// Subscribe to the count for the current room. -Tracker.autorun(() => { - Meteor.subscribe("countsByRoom", Session.get("roomId")); -}); - -// Use the new collection. -const roomCount = Counts.findOne(Session.get("roomId")).count; -console.log(`Current room has ${roomCount} messages.`); -``` - -::: warning - -Meteor will emit a warning message if you call `Meteor.publish` in a -project that includes the `autopublish` package. Your publish function -will still work. - -::: - -Read more about publications and how to use them in the -[Data Loading](http://guide.meteor.com/data-loading.html) article in the Meteor Guide. - - - -This is constant. However, if the logged-in user changes, the publish -function is rerun with the new value, assuming it didn't throw an error at the previous run. - - - - - - - -If you call [`observe`](./collections.md#Mongo-Cursor-observe) or [`observeChanges`](./collections.md#Mongo-Cursor-observeChanges) in your -publish handler, this is the place to stop the observes. - - - - - - - -When you subscribe to a record set, it tells the server to send records to the -client. The client stores these records in local [Minimongo collections](./collections.md), with the same name as the `collection` -argument used in the publish handler's [`added`](#Subscription-added), -[`changed`](#Subscription-changed), and [`removed`](#Subscription-removed) -callbacks. Meteor will queue incoming records until you declare the -[`Mongo.Collection`](./collections.md) on the client with the matching -collection name. - -```js -// It's okay to subscribe (and possibly receive data) before declaring the -// client collection that will hold it. Assume 'allPlayers' publishes data from -// the server's 'players' collection. -Meteor.subscribe("allPlayers"); - -// The client queues incoming 'players' records until the collection is created: -const Players = new Mongo.Collection("players"); -``` - -The client will see a document if the document is currently in the published -record set of any of its subscriptions. If multiple publications publish a -document with the same `_id` for the same collection the documents are merged for -the client. If the values of any of the top level fields conflict, the resulting -value will be one of the published values, chosen arbitrarily. - -::: warning -Currently, when multiple subscriptions publish the same document _only the top -level fields_ are compared during the merge. This means that if the documents -include different sub-fields of the same top level field, not all of them will -be available on the client. We hope to lift this restriction in a future release. -::: - -The `onReady` callback is called with no arguments when the server [marks the subscription as ready](#Subscription-ready). The `onStop` callback is called with -a [`Meteor.Error`](#Meteor-Error) if the subscription fails or is terminated by -the server. If the subscription is stopped by calling `stop` on the subscription -handle or inside the publication, `onStop` is called with no arguments. - -`Meteor.subscribe` returns a subscription handle, which is an object with the -following properties: - -```ts -import { Meteor } from "meteor/meteor"; -const handle = Meteor.subscribe("allPlayers"); - -handle.ready(); // True when the server has marked the subscription as ready - -handle.stop(); // Stop this subscription and unsubscribe from the server - -handle.subscriptionId; // The id of the subscription this handle is for. -``` - -When you run Meteor.subscribe inside of Tracker.autorun, the handles you get will always have the same subscriptionId field. -You can use this to deduplicate subscription handles if you are storing them in some data structure. - -If you call `Meteor.subscribe` within a reactive computation, -for example using -[`Tracker.autorun`](./Tracker#Tracker-autorun), the subscription will automatically be -cancelled when the computation is invalidated or stopped; it is not necessary -to call `stop` on -subscriptions made from inside `autorun`. However, if the next iteration -of your run function subscribes to the same record set (same name and -parameters), Meteor is smart enough to skip a wasteful -unsubscribe/resubscribe. For example: - -```js -Tracker.autorun(() => { - Meteor.subscribe("chat", { room: Session.get("currentRoom") }); - Meteor.subscribe("privateMessages"); -}); -``` - -This subscribes you to the chat messages in the current room and to your private -messages. When you change rooms by calling `Session.set('currentRoom', -'newRoom')`, Meteor will subscribe to the new room's chat messages, -unsubscribe from the original room's chat messages, and continue to -stay subscribed to your private messages. - -## Publication strategies - -> The following features are available from Meteor 2.4 or `ddp-server@2.5.0` - -Once you start scaling your application you might want to have more control on how the data from publications is being handled on the client. -There are three publications strategies: - -#### SERVER_MERGE - -`SERVER_MERGE` is the default strategy. When using this strategy, the server maintains a copy of all data a connection is subscribed to. -This allows us to only send deltas over multiple publications. - -#### NO_MERGE_NO_HISTORY - -The `NO_MERGE_NO_HISTORY` strategy results in the server sending all publication data directly to the client. -It does not remember what it has previously sent to client and will not trigger removed messages when a subscription is stopped. -This should only be chosen for special use cases like send-and-forget queues. - -#### NO_MERGE - -`NO_MERGE` is similar to `NO_MERGE_NO_HISTORY` but the server will remember the IDs it has -sent to the client so it can remove them when a subscription is stopped. -This strategy can be used when a collection is only used in a single publication. - -When `NO_MERGE` is selected the client will be handling gracefully duplicate events without throwing an exception. -Specifically: - -- When we receive an added message for a document that is already present in the client's collection, it will be changed. -- When we receive a change message for a document that is not in the client's collection, it will be added. -- When we receive a removed message for a document that is not in the client's collection, nothing will happen. - -You can import the publication strategies from `DDPServer`. - -```js -import { DDPServer } from "meteor/ddp-server"; - -const { SERVER_MERGE, NO_MERGE_NO_HISTORY, NO_MERGE } = - DDPServer.publicationStrategies; -``` - -You can use the following methods to set or get the publication strategy for publications: - - - -For the `foo` collection, you can set the `NO_MERGE` strategy as shown: - -```js -import { DDPServer } from "meteor/ddp-server"; -Meteor.server.setPublicationStrategy( - "foo", - DDPServer.publicationStrategies.NO_MERGE -); -``` - - - -### Server connections {#connections} - -Functions to manage and inspect the network connection between the Meteor client and server. - - - -```js -import { Meteor } from "meteor/meteor"; -const status = Meteor.status(); - -console.log(status); -// ^^^^ -// { -// connected: Boolean, -// status: String, -// retryCount: Number, -// retryTime: Number, -// reason: String, -// } -``` - -Status object has the following fields: - -- `connected` - _*Boolean*_ : True if currently connected to the server. If false, changes and - method invocations will be queued up until the connection is reestablished. -- `status` - _*String*_: Describes the current reconnection status. The possible - values are `connected` (the connection is up and - running), `connecting` (disconnected and trying to open a - new connection), `failed` (permanently failed to connect; e.g., the client - and server support different versions of DDP), `waiting` (failed - to connect and waiting to try to reconnect) and `offline` (user has disconnected the connection). -- `retryCount` - _*Number*_: The number of times the client has tried to reconnect since the - connection was lost. 0 when connected. -- `retryTime` - _*Number or undefined*_: The estimated time of the next reconnection attempt. To turn this - into an interval until the next reconnection, This key will be set only when `status` is `waiting`. - You canuse this snippet: - ```js - retryTime - new Date().getTime(); - ``` -- `reason` - _*String or undefined*_: If `status` is `failed`, a description of why the connection failed. - - - - - -Call this method to disconnect from the server and stop all -live data updates. While the client is disconnected it will not receive -updates to collections, method calls will be queued until the -connection is reestablished, and hot code push will be disabled. - -Call [Meteor.reconnect](#Meteor-reconnect) to reestablish the connection -and resume data transfer. - -This can be used to save battery on mobile devices when real time -updates are not required. - - - -```js -import { Meteor } from "meteor/meteor"; - -const handle = Meteor.onConnection((connection) => { - console.log(connection); - // ^^^^^^^^^^^ - // { - // id: String, - // close: Function, - // onClose: Function, - // clientAddress: String, - // httpHeaders: Object, - // } -}); - -handle.stop(); // Unregister the callback -``` - -`onConnection` returns an object with a single method `stop`. Calling -`stop` unregisters the callback, so that this callback will no longer -be called on new connections. - -The callback is called with a single argument, the server-side -`connection` representing the connection from the client. This object -contains the following fields: - -- `id` - _*String*_: A globally unique id for this connection. -- `close` - _*Function*_: Close this DDP connection. The client is free to reconnect, but will - receive a different connection with a new `id` if it does. -- `onClose` - _*Function*_: Register a callback to be called when the connection is closed. - If the connection is already closed, the callback will be called immediately. -- `clientAddress` - _*String*_: The IP address of the client in dotted form (such as `127.0.0.1`). If you're running your Meteor server behind a proxy (so that clients - are connecting to the proxy instead of to your server directly), - you'll need to set the `HTTP_FORWARDED_COUNT` environment variable - for the correct IP address to be reported by `clientAddress`. - - Set `HTTP_FORWARDED_COUNT` to an integer representing the number of - proxies in front of your server. For example, you'd set it to `1` - when your server was behind one proxy. - -- `httpHeaders` - _*Object*_: When the connection came in over an HTTP transport (such as with - Meteor's default SockJS implementation), this field contains - whitelisted HTTP headers. - - Cookies are deliberately excluded from the headers as they are a - security risk for this transport. For details and alternatives, see - the [SockJS documentation](https://github.com/sockjs/sockjs-node#authorisation). - -> Currently when a client reconnects to the server (such as after -> temporarily losing its Internet connection), it will get a new -> connection each time. The `onConnection` callbacks will be called -> again, and the new connection will have a new connection `id`. - -> In the future, when client reconnection is fully implemented, -> reconnecting from the client will reconnect to the same connection on -> the server: the `onConnection` callback won't be called for that -> connection again, and the connection will still have the same -> connection `id`. - - - -```js -import { DDP } from "meteor/ddp-client"; -import { Mongo } from "meteor/mongo"; -import { Meteor } from "meteor/meteor"; -const options = {...}; - -const otherServer = DDP.connect("http://example.com", options); - -otherServer.call("foo.from.other.server", 1, 2, function (err, result) { - // ... -}); - -Metepr.call("foo.from.this.server", 1, 2, function (err, result) { - // ... -}); -const remoteColl = new Mongo.Collection("collectionName", { connection: otherServer }); -remoteColl.find(...); - - -``` - -To call methods on another Meteor application or subscribe to its data -sets, call `DDP.connect` with the URL of the application. -`DDP.connect` returns an object which provides: - -- `subscribe` - - Subscribe to a record set. See - [Meteor.subscribe](#Meteor-subscribe). -- `call` - - Invoke a method. See [Meteor.call](#Meteor-call). -- `apply` - - Invoke a method with an argument array. See - [Meteor.apply](#Meteor-apply). -- `methods` - - Define client-only stubs for methods defined on the remote server. See - [Meteor.methods](#Meteor-methods). -- `status` - - Get the current connection status. See - [Meteor.status](#Meteor-status). -- `reconnect` - - See [Meteor.reconnect](#Meteor-reconnect). -- `disconnect` - - See [Meteor.disconnect](#Meteor-disconnect). - -By default, clients open a connection to the server from which they're loaded. -When you call `Meteor.subscribe`, `Meteor.status`, `Meteor.call`, and -`Meteor.apply`, you are using a connection back to that default -server. - - - -## Timers { #timers } - -Meteor uses global environment variables -to keep track of things like the current request's user. To make sure -these variables have the right values, you need to use -`Meteor.setTimeout` instead of `setTimeout` and `Meteor.setInterval` -instead of `setInterval`. - -These functions work just like their native JavaScript equivalents. -If you call the native function, you'll get an error stating that Meteor -code must always run within a Fiber, and advising to use -`Meteor.bindEnvironment`. - - - -Returns a handle that can be used by `Meteor.clearTimeout`. - - - -Returns a handle that can be used by `Meteor.clearInterval`. - - - - -## Enviroment variables {#envs} - -Meteor implements `Meteor.EnvironmentVariable` with AsyncLocalStorage, which allows for maintaining context across asynchronous boundaries. `Meteor.EnvironmentVariable` works with `Meteor.bindEnvironment`, promises, and many other Meteor API's to preserve the context in async code. Some examples of how it is used in Meteor are to store the current user in methods, and record which arguments have been checked when using `audit-argument-checks`. - -```js -import { Meteor } from "meteor/meteor"; -const currentRequest = new Meteor.EnvironmentVariable(); - -function log(message) { - const requestId = currentRequest.get() || "None"; - console.log(`[${requestId}]`, message); -} - -currentRequest.withValue("12345", () => { - log("Handling request"); // Logs: [12345] Handling request -}); -``` - - - - - - - diff --git a/comparison/supabase.md b/comparison/supabase.md deleted file mode 100644 index 3e25d6f..0000000 --- a/comparison/supabase.md +++ /dev/null @@ -1,709 +0,0 @@ ---- -id: "getting-started" -title: "Getting Started with Realtime" -description: "Learn how to build real-time applications with Supabase Realtime" -subtitle: "Learn how to build real-time applications with Supabase Realtime" -sidebar_label: "Getting Started" ---- - -## Quick start - -### 1. Install the client library - - - -```bash -npm install @supabase/supabase-js -``` - - -<$Show if="sdk:dart"> - - -```bash -flutter pub add supabase_flutter -``` - - - -<$Show if="sdk:swift"> - - -```swift -let package = Package( - // ... - dependencies: [ - // ... - .package( - url: "https://github.com/supabase/supabase-swift.git", - from: "2.0.0" - ), - ], - targets: [ - .target( - name: "YourTargetName", - dependencies: [ - .product( - name: "Supabase", - package: "supabase-swift" - ), - ] - ) - ] -) -``` - - - -<$Show if="sdk:python"> - - -```bash -pip install supabase -``` - - - - -```bash -conda install -c conda-forge supabase -``` - - - - - -### 2. Initialize the client - -Get your project URL and key. -<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> - - - -```ts -import { createClient } from "@supabase/supabase-js"; - -const supabase = createClient( - "https://.supabase.co", - "" -); -``` - - -<$Show if="sdk:dart"> - - -```dart -import 'package:supabase_flutter/supabase_flutter.dart'; - -void main() async { - await Supabase.initialize( - url: 'https://.supabase.co', - anonKey: '', - ); - runApp(MyApp()); -} - -final supabase = Supabase.instance.client; -``` - - - -<$Show if="sdk:swift"> - - -```swift -import Supabase - -let supabase = SupabaseClient( - supabaseURL: URL(string: "https://.supabase.co")!, - supabaseKey: "" -) -``` - - - -<$Show if="sdk:python"> - - -```python -from supabase import create_client, Client - -url: str = "https://.supabase.co" -key: str = "" -supabase: Client = create_client(url, key) -``` - - - - - -### 3. Create your first Channel - -Channels are the foundation of Realtime. Think of them as rooms where clients can communicate. Each channel is identified by a topic name and if they are public or private. - - - -```ts -// Create a channel with a descriptive topic name -const channel = supabase.channel("room:lobby:messages", { - config: { private: true }, // Recommended for production -}); -``` - - -<$Show if="sdk:dart"> - - -```dart -// Create a channel with a descriptive topic name -final channel = supabase.channel('room:lobby:messages'); -``` - - - -<$Show if="sdk:swift"> - - -```swift -// Create a channel with a descriptive topic name -let channel = supabase.channel("room:lobby:messages") { - $0.isPrivate = true -} -``` - - - -<$Show if="sdk:python"> - - -```python -# Create a channel with a descriptive topic name -channel = supabase.channel('room:lobby:messages', params={config={private= True }}) -``` - - - - - -### 4. Set up authorization - -Since we're using a private channel, you need to create a basic RLS policy on the `realtime.messages` table to allow authenticated users to connect. Row Level Security (RLS) policies control who can access your Realtime channels based on user authentication and custom rules: - -```sql --- Allow authenticated users to receive broadcasts -CREATE POLICY "authenticated_users_can_receive" ON realtime.messages - FOR SELECT TO authenticated USING (true); - --- Allow authenticated users to send broadcasts -CREATE POLICY "authenticated_users_can_send" ON realtime.messages - FOR INSERT TO authenticated WITH CHECK (true); -``` - -### 5. Send and receive messages - -There are three main ways to send messages with Realtime: - -#### 5.1 using client libraries - -Send and receive messages using the Supabase client: - - - -```ts -// Listen for messages -channel - .on("broadcast", { event: "message_sent" }, (payload: { payload: any }) => { - console.log("New message:", payload.payload); - }) - .subscribe(); - -// Send a message -channel.send({ - type: "broadcast", - event: "message_sent", - payload: { - text: "Hello, world!", - user: "john_doe", - timestamp: new Date().toISOString(), - }, -}); -``` - - -<$Show if="sdk:dart"> - - -```dart -// Listen for messages -channel.onBroadcast( - event: 'message_sent', - callback: (payload) { - print('New message: ${payload['payload']}'); - }, -).subscribe(); - -// Send a message -channel.sendBroadcastMessage( - event: 'message_sent', - payload: { - 'text': 'Hello, world!', - 'user': 'john_doe', - 'timestamp': DateTime.now().toIso8601String(), - }, -); -``` - - - -<$Show if="sdk:swift"> - - -```swift -// Listen for messages -await channel.onBroadcast(event: "message_sent") { message in - print("New message: \(message.payload)") -} - -let status = await channel.subscribe() - -// Send a message -await channel.sendBroadcastMessage( - event: "message_sent", - payload: [ - "text": "Hello, world!", - "user": "john_doe", - "timestamp": ISO8601DateFormatter().string(from: Date()) - ] -) -``` - - - -<$Show if="sdk:python"> - - -```python -# Listen for messages -def message_handler(payload): - print(f"New message: {payload['payload']}") - -channel.on_broadcast(event="message_sent", callback=message_handler).subscribe() - -# Send a message -channel.send_broadcast_message( - event="message_sent", - payload={ - "text": "Hello, world!", - "user": "john_doe", - "timestamp": datetime.now().isoformat() - } -) -``` - - - - - -#### 5.2 using HTTP/REST API - -Send messages via HTTP requests, perfect for server-side applications: - - - -```ts -// Send message via REST API -const response = await fetch( - `https://.supabase.co/rest/v1/rpc/broadcast`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer `, - apikey: "", - }, - body: JSON.stringify({ - topic: "room:lobby:messages", - event: "message_sent", - payload: { - text: "Hello from server!", - user: "system", - timestamp: new Date().toISOString(), - }, - private: true, - }), - } -); -``` - - -<$Show if="sdk:dart"> - - -```dart -import 'package:http/http.dart' as http; -import 'dart:convert'; - -// Send message via REST API -final response = await http.post( - Uri.parse('https://.supabase.co/rest/v1/rpc/broadcast'), - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ', - 'apikey': '', - }, - body: jsonEncode({ - 'topic': 'room:lobby:messages', - 'event': 'message_sent', - 'payload': { - 'text': 'Hello from server!', - 'user': 'system', - 'timestamp': DateTime.now().toIso8601String(), - }, - 'private': true, - }), -); -``` - - - -<$Show if="sdk:swift"> - - -```swift -import Foundation - -// Send message via REST API -let url = URL(string: "https://.supabase.co/rest/v1/rpc/broadcast")! -var request = URLRequest(url: url) -request.httpMethod = "POST" -request.setValue("application/json", forHTTPHeaderField: "Content-Type") -request.setValue("Bearer ", forHTTPHeaderField: "Authorization") -request.setValue("", forHTTPHeaderField: "apikey") - -let payload = [ - "topic": "room:lobby:messages", - "event": "message_sent", - "payload": [ - "text": "Hello from server!", - "user": "system", - "timestamp": ISO8601DateFormatter().string(from: Date()) - ], - "private": true -] as [String: Any] - -request.httpBody = try JSONSerialization.data(withJSONObject: payload) - -let (data, response) = try await URLSession.shared.data(for: request) -``` - - - -<$Show if="sdk:python"> - - -```python -import requests -from datetime import datetime - -# Send message via REST API -response = requests.post( - 'https://.supabase.co/rest/v1/rpc/broadcast', - headers={ - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ', - 'apikey': '' - }, - json={ - 'topic': 'room:lobby:messages', - 'event': 'message_sent', - 'payload': { - 'text': 'Hello from server!', - 'user': 'system', - 'timestamp': datetime.now().isoformat() - }, - 'private': True - } -) -``` - - - - - -#### 5.3 using database triggers - -Automatically broadcast database changes using triggers. Choose the approach that best fits your needs: - -**Using `realtime.broadcast_changes` (Best for mirroring database changes)** - -```sql --- Create a trigger function for broadcasting database changes -CREATE OR REPLACE FUNCTION broadcast_message_changes() -RETURNS TRIGGER AS $$ -BEGIN - -- Broadcast to room-specific channel - PERFORM realtime.broadcast_changes( - 'room:' || NEW.room_id::text || ':messages', - TG_OP, - TG_OP, - TG_TABLE_NAME, - TG_TABLE_SCHEMA, - NEW, - OLD - ); - RETURN NULL; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Apply trigger to your messages table -CREATE TRIGGER messages_broadcast_trigger - AFTER INSERT OR UPDATE OR DELETE ON messages - FOR EACH ROW EXECUTE FUNCTION broadcast_message_changes(); -``` - -**Using `realtime.send` (Best for custom notifications and filtered data)** - -```sql --- Create a trigger function for custom notifications -CREATE OR REPLACE FUNCTION notify_message_activity() -RETURNS TRIGGER AS $$ -BEGIN - -- Send custom notification when new message is created - IF TG_OP = 'INSERT' THEN - PERFORM realtime.send( - 'room:' || NEW.room_id::text || ':notifications', - 'message_created', - jsonb_build_object( - 'message_id', NEW.id, - 'user_id', NEW.user_id, - 'room_id', NEW.room_id, - 'created_at', NEW.created_at - ), - true -- private channel - ); - END IF; - - RETURN NULL; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Apply trigger to your messages table -CREATE TRIGGER messages_notification_trigger - AFTER INSERT ON messages - FOR EACH ROW EXECUTE FUNCTION notify_message_activity(); -``` - -- **`realtime.broadcast_changes`** sends the full database change with metadata -- **`realtime.send`** allows you to send custom payloads and control exactly what data is broadcast - -## Essential best practices - -### Use private channels - -Always use private channels for production applications to ensure proper security and authorization: - -```ts -const channel = supabase.channel("room:123:messages", { - config: { private: true }, -}); -``` - -### Follow naming conventions - -**Channel Topics:** Use the pattern `scope:id:entity` - -- `room:123:messages` - Messages in room 123 -- `game:456:moves` - Game moves for game 456 -- `user:789:notifications` - Notifications for user 789 - -### Clean up subscriptions - -Always unsubscribe when you are done with a channel to ensure you free up resources: - - - -```ts -// React example -import { useEffect } from "react"; - -useEffect(() => { - const channel = supabase.channel("room:123:messages"); - - return () => { - supabase.removeChannel(channel); - }; -}, []); -``` - - -<$Show if="sdk:dart"> - - -```dart -// Flutter example -class _MyWidgetState extends State { - RealtimeChannel? _channel; - - @override - void initState() { - super.initState(); - _channel = supabase.channel('room:123:messages'); - } - - @override - void dispose() { - _channel?.unsubscribe(); - super.dispose(); - } -} -``` - - - -<$Show if="sdk:swift"> - - -```swift -// SwiftUI example -struct ContentView: View { - @State private var channel: RealtimeChannelV2? - - var body: some View { - // Your UI here - .onAppear { - channel = supabase.realtimeV2.channel("room:123:messages") - } - .onDisappear { - Task { - await channel?.unsubscribe() - } - } - } -} -``` - - - -<$Show if="sdk:python"> - - -```python -# Python example with context manager -class RealtimeManager: - def __init__(self): - self.channel = None - - def __enter__(self): - self.channel = supabase.channel('room:123:messages') - return self.channel - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.channel: - self.channel.unsubscribe() - -# Usage -with RealtimeManager() as channel: - # Use channel here - pass -``` - - - - - -## Choose the right feature - -### When to use Broadcast - -- Real-time messaging and notifications -- Custom events and game state -- Database change notifications (with triggers) -- High-frequency updates (e.g. Cursor tracking) -- Most use cases - -### When to use Presence - -- User online/offline status -- Active user counters -- Use minimally due to computational overhead - -### When to use Postgres Changes - -- Quick testing and development -- Low amount of connected users - -## Next steps - -Now that you understand the basics, dive deeper into each feature: - -### Core features - -- **[Broadcast](/docs/guides/realtime/broadcast)** - Learn about sending messages, database triggers, and REST API usage -- **[Presence](/docs/guides/realtime/presence)** - Implement user state tracking and online indicators -- **[Postgres Changes](/docs/guides/realtime/postgres-changes)** - Understanding database change listeners (consider migrating to Broadcast) - -### Security & configuration - -- **[Authorization](/docs/guides/realtime/authorization)** - Set up RLS policies for private channels -- **[Settings](/docs/guides/realtime/settings)** - Configure your Realtime instance for optimal performance - -### Advanced topics - -- **[Architecture](/docs/guides/realtime/architecture)** - Understand how Realtime works under the hood -- **[Benchmarks](/docs/guides/realtime/benchmarks)** - Performance characteristics and scaling considerations -- **[Quotas](/docs/guides/realtime/quotas)** - Usage limits and best practices - -### Integration guides - -- **[Realtime with Next.js](/docs/guides/realtime/realtime-with-nextjs)** - Build real-time Next.js applications -- **[User Presence](/docs/guides/realtime/realtime-user-presence)** - Implement user presence features -- **[Database Changes](/docs/guides/realtime/subscribing-to-database-changes)** - Listen to database changes - -### Framework examples - -- **[Flutter Integration](/docs/guides/realtime/realtime-listening-flutter)** - Build real-time Flutter applications - -Ready to build something amazing? Start with the [Broadcast guide](/docs/guides/realtime/broadcast) to create your first real-time feature! diff --git a/realtime-api-proposal.md b/realtime-api-proposal.md deleted file mode 100644 index f423111..0000000 --- a/realtime-api-proposal.md +++ /dev/null @@ -1,330 +0,0 @@ -# Realtime API Proposal - -This document outlines the proposed public API for realtime entity subscriptions in the Base44 SDK. - -## Overview - -The realtime API enables users to subscribe to live updates on entities. It supports: - -- **All entity changes**: Subscribe to any create/update/delete on an entity type -- **Single entity instance**: Subscribe to changes on a specific entity by ID -- **Query-based subscriptions**: Subscribe to entities matching a filter (e.g., all completed tasks) - ---- - -## Type Definitions - -```typescript -// In entities.types.ts - -/** - * Event types for realtime entity updates. - */ -export type RealtimeEventType = "create" | "update" | "delete"; - -/** - * Payload received when a realtime event occurs. - */ -export interface RealtimeEvent> { - /** The type of change that occurred */ - type: RealtimeEventType; - /** The entity data (new/updated for create/update, previous for delete) */ - data: T; - /** The unique identifier of the affected entity */ - id: string; - /** ISO 8601 timestamp of when the event occurred */ - timestamp: string; - /** For update events, contains the previous data before the change */ - previousData?: T; -} - -/** - * Callback function invoked when a realtime event occurs. - */ -export type RealtimeCallback> = ( - event: RealtimeEvent -) => void; - -/** - * Options for subscribing to realtime updates. - */ -export interface SubscribeOptions { - /** Filter events by type. Defaults to all types. */ - events?: RealtimeEventType[]; -} - -/** - * Handle returned from subscribe, used to unsubscribe. - */ -export interface Subscription { - /** Stops listening to updates and cleans up the subscription. */ - unsubscribe: () => void; -} -``` - ---- - -## Extended EntityHandler Interface - -````typescript -export interface EntityHandler { - // ... existing methods (list, filter, get, create, update, delete, etc.) ... - - /** - * Subscribes to realtime updates for all records of this entity type. - * - * Receives notifications whenever any record is created, updated, or deleted. - * - * @param callback - Function called when an entity changes. - * @param options - Optional configuration for filtering events. - * @returns Subscription handle with an unsubscribe method. - * - * @example - * ```typescript - * // Subscribe to all Task changes - * const subscription = base44.entities.Task.subscribe((event) => { - * console.log(`Task ${event.id} was ${event.type}d:`, event.data); - * }); - * - * // Later, unsubscribe - * subscription.unsubscribe(); - * ``` - * - * @example - * ```typescript - * // Subscribe only to create events - * const subscription = base44.entities.Task.subscribe( - * (event) => console.log('New task:', event.data), - * { events: ['create'] } - * ); - * ``` - */ - subscribe( - callback: RealtimeCallback, - options?: SubscribeOptions - ): Subscription; - - /** - * Subscribes to realtime updates for a specific entity record. - * - * Receives notifications when the specified record is updated or deleted. - * - * @param id - The unique identifier of the record to watch. - * @param callback - Function called when the entity changes. - * @param options - Optional configuration for filtering events. - * @returns Subscription handle with an unsubscribe method. - * - * @example - * ```typescript - * // Subscribe to a specific task - * const subscription = base44.entities.Task.subscribe('task-123', (event) => { - * if (event.type === 'update') { - * console.log('Task updated:', event.data); - * } else if (event.type === 'delete') { - * console.log('Task was deleted'); - * } - * }); - * ``` - */ - subscribe( - id: string, - callback: RealtimeCallback, - options?: SubscribeOptions - ): Subscription; - - /** - * Subscribes to realtime updates for records matching a query. - * - * Receives notifications for records that match the specified criteria. - * Includes create events when new records match the query, update events - * when matching records change, and delete events when matching records - * are removed. - * - * @param query - Query object with field-value pairs to filter records. - * @param callback - Function called when a matching entity changes. - * @param options - Optional configuration for filtering events. - * @returns Subscription handle with an unsubscribe method. - * - * @example - * ```typescript - * // Subscribe to all completed tasks - * const subscription = base44.entities.Task.subscribe( - * { isCompleted: true }, - * (event) => { - * console.log(`Completed task ${event.type}:`, event.data); - * } - * ); - * ``` - * - * @example - * ```typescript - * // Subscribe to high-priority active tasks - * const subscription = base44.entities.Task.subscribe( - * { priority: 'high', status: 'active' }, - * (event) => console.log('High priority task changed:', event.data) - * ); - * ``` - */ - subscribe( - query: Record, - callback: RealtimeCallback, - options?: SubscribeOptions - ): Subscription; -} -```` - ---- - -## Usage Examples - -### 1. Subscribe to ALL changes on an entity type - -```typescript -const allTasksSub = base44.entities.Task.subscribe((event) => { - console.log(`Task ${event.id} was ${event.type}d`); - console.log("Data:", event.data); -}); -``` - -### 2. Subscribe to a SPECIFIC entity instance by ID - -```typescript -const singleTaskSub = base44.entities.Task.subscribe("task-123", (event) => { - if (event.type === "update") { - console.log("Task updated:", event.data); - console.log("Previous:", event.previousData); - } else if (event.type === "delete") { - console.log("Task was deleted"); - } -}); -``` - -### 3. Subscribe to entities matching a QUERY - -```typescript -const completedTasksSub = base44.entities.Task.subscribe( - { isCompleted: true }, - (event) => { - console.log("Completed task changed:", event.type, event.data); - } -); -``` - -### 4. Filter by EVENT TYPE (only listen to creates) - -```typescript -const newTasksSub = base44.entities.Task.subscribe( - (event) => console.log("New task created:", event.data), - { events: ["create"] } -); -``` - -### 5. Combined: query + event filter - -```typescript -const newHighPrioritySub = base44.entities.Task.subscribe( - { priority: "high" }, - (event) => console.log("New high-priority task:", event.data), - { events: ["create"] } -); -``` - -### 6. Works with service role too - -```typescript -const adminSub = base44.asServiceRole.entities.User.subscribe((event) => { - console.log("User changed:", event.type, event.data); -}); -``` - -### 7. Cleanup - -```typescript -allTasksSub.unsubscribe(); -singleTaskSub.unsubscribe(); -completedTasksSub.unsubscribe(); -``` - ---- - -## Room Naming Convention (Internal) - -Based on the existing socket infrastructure, the room names follow this pattern: - -| Subscription Type | Room Name Format | -| ------------------ | ------------------------------------------------- | -| All entity changes | `entities:{appId}:{entityName}` | -| Single entity | `entities:{appId}:{entityName}:{entityId}` | -| Query-based | `entities:{appId}:{entityName}:query:{queryHash}` | - ---- - -## Design Decisions - -| Aspect | Choice | Rationale | -| ------------------------- | --------------------------------------------- | ----------------------------------------------------------- | -| **Method name** | `subscribe` | Matches Appwrite pattern, intuitive | -| **Callback position** | Callback before options (or after ID/query) | Matches SDK pattern where main data comes first | -| **Returns** | `Subscription` object with `unsubscribe()` | Clean, explicit cleanup; matches Appwrite/Supabase patterns | -| **Event payload** | Object with `type`, `data`, `id`, `timestamp` | Comprehensive info like Appwrite, typed for TypeScript | -| **Overloaded signatures** | 3 variants (all, by ID, by query) | Ergonomic API that covers all use cases | -| **Options parameter** | Optional event filtering | Extensible for future options | - ---- - -## Comparison with Similar Products - -### Appwrite - -```javascript -client.subscribe("databases.A.tables.A.rows.A", (response) => { - console.log(response.payload); -}); -``` - -- Uses channel strings for targeting -- Returns unsubscribe function directly -- Callback receives `{ events, channels, timestamp, payload }` - -### Supabase - -```typescript -const channel = supabase - .channel("room:123:messages") - .on("broadcast", { event: "message_sent" }, (payload) => { - console.log("New message:", payload); - }) - .subscribe(); -``` - -- Channel-based with chained `.on().subscribe()` pattern -- Separates channel creation from event listening - -### Meteor - -```javascript -Meteor.subscribe('roomAndMessages', roomId); - -// With observeChanges -Messages.find({ roomId }).observeChanges({ - added: (id, fields) => { ... }, - changed: (id, fields) => { ... }, - removed: (id) => { ... } -}); -``` - -- Separate `added`, `changed`, `removed` callbacks -- Cursor-based observation - -### Our Proposed API - -```typescript -base44.entities.Task.subscribe({ isCompleted: true }, (event) => { - console.log(event.type, event.data); -}); -``` - -- Matches existing SDK style (`base44.entities.EntityName.method()`) -- Single callback with event type in payload -- Overloaded for flexibility (all, by ID, by query) -- Returns subscription handle with `unsubscribe()` method From bb1d3f67e1f5d65cfe36dde2fd80e6312eb7fdfd Mon Sep 17 00:00:00 2001 From: johnny Date: Sun, 4 Jan 2026 15:37:02 +0200 Subject: [PATCH 04/14] no previousData, no SubscribeOptions --- src/index.ts | 1 - src/modules/entities.ts | 61 +++--------------------- src/modules/entities.types.ts | 89 +---------------------------------- 3 files changed, 7 insertions(+), 144 deletions(-) diff --git a/src/index.ts b/src/index.ts index 27159e9..752f560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,6 @@ export type { RealtimeEventType, RealtimeEvent, RealtimeCallback, - SubscribeOptions, Subscription, } from "./modules/entities.types.js"; diff --git a/src/modules/entities.ts b/src/modules/entities.ts index f64194c..88586fc 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -5,7 +5,6 @@ import { RealtimeCallback, RealtimeEvent, RealtimeEventType, - SubscribeOptions, Subscription, } from "./entities.types"; import { RoomsSocket } from "../utils/socket-utils"; @@ -85,25 +84,6 @@ export function createEntitiesModule( ) as EntitiesModule; } -/** - * Creates a stable hash from a query object for room naming. - * @internal - */ -function hashQuery(query: Record): string { - const sortedKeys = Object.keys(query).sort(); - const normalized = sortedKeys - .map((k) => `${k}:${JSON.stringify(query[k])}`) - .join("|"); - // Simple hash function - let hash = 0; - for (let i = 0; i < normalized.length; i++) { - const char = normalized.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(36); -} - /** * Parses the realtime message data and extracts event information. * @internal @@ -116,7 +96,6 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { data: parsed.data, id: parsed.id || parsed.data?.id, timestamp: parsed.timestamp || new Date().toISOString(), - previousData: parsed.previousData, }; } catch { return null; @@ -218,48 +197,20 @@ function createEntityHandler( }, // Subscribe to realtime updates - subscribe( - callbackOrIdOrQuery: RealtimeCallback | string | Record, - callbackOrOptions?: RealtimeCallback | SubscribeOptions, - optionsArg?: SubscribeOptions - ): Subscription { - let room: string; - let callback: RealtimeCallback; - let options: SubscribeOptions | undefined; - - // Parse overloaded arguments - if (typeof callbackOrIdOrQuery === "function") { - // subscribe(callback, options?) - room = `entities:${appId}:${entityName}`; - callback = callbackOrIdOrQuery as RealtimeCallback; - options = callbackOrOptions as SubscribeOptions | undefined; - } else if (typeof callbackOrIdOrQuery === "string") { - // subscribe(id, callback, options?) - room = `entities:${appId}:${entityName}:${callbackOrIdOrQuery}`; - callback = callbackOrOptions as RealtimeCallback; - options = optionsArg; - } else { - // subscribe(query, callback, options?) - const queryHash = hashQuery(callbackOrIdOrQuery); - room = `entities:${appId}:${entityName}:query:${queryHash}`; - callback = callbackOrOptions as RealtimeCallback; - options = optionsArg; - } - - const eventFilter = options?.events; + subscribe(callback: RealtimeCallback): Subscription { + const room = `entities:${appId}:${entityName}`; // Get the socket and subscribe to the room const socket = getSocket(); const unsubscribe = socket.subscribeToRoom(room, { update_model: (msg) => { // Only process messages for our room - if (msg.room !== room) return; + if (msg.room !== room) { + return; + } const event = parseRealtimeMessage(msg.data); - if (!event) return; - - // Apply event type filter if specified - if (eventFilter && !eventFilter.includes(event.type)) { + if (!event) { return; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 8b99d68..c4e5f27 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -15,8 +15,6 @@ export interface RealtimeEvent> { id: string; /** ISO 8601 timestamp of when the event occurred */ timestamp: string; - /** For update events, contains the previous data before the change */ - previousData?: T; } /** @@ -26,14 +24,6 @@ export type RealtimeCallback> = ( event: RealtimeEvent ) => void; -/** - * Options for subscribing to realtime updates. - */ -export interface SubscribeOptions { - /** Filter events by type. Defaults to all types. */ - events?: RealtimeEventType[]; -} - /** * Handle returned from subscribe, used to unsubscribe. */ @@ -312,7 +302,6 @@ export interface EntityHandler { * Receives notifications whenever any record is created, updated, or deleted. * * @param callback - Function called when an entity changes. - * @param options - Optional configuration for filtering events. * @returns Subscription handle with an unsubscribe method. * * @example @@ -325,84 +314,8 @@ export interface EntityHandler { * // Later, unsubscribe * subscription.unsubscribe(); * ``` - * - * @example - * ```typescript - * // Subscribe only to create events - * const subscription = base44.entities.Task.subscribe( - * (event) => console.log("New task:", event.data), - * { events: ["create"] } - * ); - * ``` */ - subscribe(callback: RealtimeCallback, options?: SubscribeOptions): Subscription; - - /** - * Subscribes to realtime updates for a specific entity record. - * - * Receives notifications when the specified record is updated or deleted. - * - * @param id - The unique identifier of the record to watch. - * @param callback - Function called when the entity changes. - * @param options - Optional configuration for filtering events. - * @returns Subscription handle with an unsubscribe method. - * - * @example - * ```typescript - * // Subscribe to a specific task - * const subscription = base44.entities.Task.subscribe("task-123", (event) => { - * if (event.type === "update") { - * console.log("Task updated:", event.data); - * } else if (event.type === "delete") { - * console.log("Task was deleted"); - * } - * }); - * ``` - */ - subscribe( - id: string, - callback: RealtimeCallback, - options?: SubscribeOptions - ): Subscription; - - /** - * Subscribes to realtime updates for records matching a query. - * - * Receives notifications for records that match the specified criteria. - * Includes create events when new records match the query, update events - * when matching records change, and delete events when matching records - * are removed. - * - * @param query - Query object with field-value pairs to filter records. - * @param callback - Function called when a matching entity changes. - * @param options - Optional configuration for filtering events. - * @returns Subscription handle with an unsubscribe method. - * - * @example - * ```typescript - * // Subscribe to all completed tasks - * const subscription = base44.entities.Task.subscribe( - * { isCompleted: true }, - * (event) => { - * console.log(`Completed task ${event.type}:`, event.data); - * } - * ); - * ``` - * - * @example - * ```typescript - * // Subscribe to high-priority active tasks - * const subscription = base44.entities.Task.subscribe( - * { priority: "high", status: "active" }, - * (event) => console.log("High priority task changed:", event.data) - * ); - * ``` - */ - subscribe( - query: Record, - callback: RealtimeCallback, - options?: SubscribeOptions - ): Subscription; + subscribe(callback: RealtimeCallback): Subscription; } /** From 8d63637f491f412dd01ab3d73adf5c5555a32131 Mon Sep 17 00:00:00 2001 From: johnny Date: Sun, 4 Jan 2026 15:38:29 +0200 Subject: [PATCH 05/14] return unsubscribe directly --- src/modules/entities.ts | 4 +--- src/modules/entities.types.ts | 13 +++++-------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 88586fc..1156744 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -218,9 +218,7 @@ function createEntityHandler( }, }); - return { - unsubscribe, - }; + return unsubscribe; }, }; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index c4e5f27..155f78f 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -25,12 +25,9 @@ export type RealtimeCallback> = ( ) => void; /** - * Handle returned from subscribe, used to unsubscribe. + * Function returned from subscribe, call it to unsubscribe. */ -export interface Subscription { - /** Stops listening to updates and cleans up the subscription. */ - unsubscribe: () => void; -} +export type Subscription = () => void; /** * Entity handler providing CRUD operations for a specific entity type. @@ -302,17 +299,17 @@ export interface EntityHandler { * Receives notifications whenever any record is created, updated, or deleted. * * @param callback - Function called when an entity changes. - * @returns Subscription handle with an unsubscribe method. + * @returns Unsubscribe function to stop listening. * * @example * ```typescript * // Subscribe to all Task changes - * const subscription = base44.entities.Task.subscribe((event) => { + * const unsubscribe = base44.entities.Task.subscribe((event) => { * console.log(`Task ${event.id} was ${event.type}d:`, event.data); * }); * * // Later, unsubscribe - * subscription.unsubscribe(); + * unsubscribe(); * ``` */ subscribe(callback: RealtimeCallback): Subscription; From 4d2197d18ddbf0c637fd09c529c80e5db92490b5 Mon Sep 17 00:00:00 2001 From: johnny Date: Sun, 4 Jan 2026 15:45:56 +0200 Subject: [PATCH 06/14] redundent --- src/modules/entities.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 1156744..711ec11 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -204,11 +204,6 @@ function createEntityHandler( const socket = getSocket(); const unsubscribe = socket.subscribeToRoom(room, { update_model: (msg) => { - // Only process messages for our room - if (msg.room !== room) { - return; - } - const event = parseRealtimeMessage(msg.data); if (!event) { return; From fa7c0ae5ba15c207d3e87c020d9de04546c15aee Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 10:26:43 +0200 Subject: [PATCH 07/14] revert introduction of generics --- src/modules/entities.types.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 155f78f..2251244 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -6,11 +6,11 @@ export type RealtimeEventType = "create" | "update" | "delete"; /** * Payload received when a realtime event occurs. */ -export interface RealtimeEvent> { +export interface RealtimeEvent { /** The type of change that occurred */ type: RealtimeEventType; - /** The entity data (new/updated for create/update, previous for delete) */ - data: T; + /** The entity data */ + data: any; /** The unique identifier of the affected entity */ id: string; /** ISO 8601 timestamp of when the event occurred */ @@ -20,9 +20,7 @@ export interface RealtimeEvent> { /** * Callback function invoked when a realtime event occurs. */ -export type RealtimeCallback> = ( - event: RealtimeEvent -) => void; +export type RealtimeCallback = (event: RealtimeEvent) => void; /** * Function returned from subscribe, call it to unsubscribe. From 5ea6914dfe9d455a12ac409e910dd12f6527947a Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 10:29:41 +0200 Subject: [PATCH 08/14] add warning on parsing failure --- src/modules/entities.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 711ec11..3933da1 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -97,7 +97,8 @@ function parseRealtimeMessage(dataStr: string): RealtimeEvent | null { id: parsed.id || parsed.data?.id, timestamp: parsed.timestamp || new Date().toISOString(), }; - } catch { + } catch (error) { + console.warn("[Base44 SDK] Failed to parse realtime message:", error); return null; } } From eef016865d2817d52510ec3a6a50ece24dbb1a55 Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 15:25:22 +0200 Subject: [PATCH 09/14] make subscribe async --- src/modules/entities.ts | 2 +- src/modules/entities.types.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 3933da1..9e17c3a 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -198,7 +198,7 @@ function createEntityHandler( }, // Subscribe to realtime updates - subscribe(callback: RealtimeCallback): Subscription { + async subscribe(callback: RealtimeCallback): Promise { const room = `entities:${appId}:${entityName}`; // Get the socket and subscribe to the room diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 2251244..81ca61f 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -297,12 +297,12 @@ export interface EntityHandler { * Receives notifications whenever any record is created, updated, or deleted. * * @param callback - Function called when an entity changes. - * @returns Unsubscribe function to stop listening. + * @returns Promise resolving to an unsubscribe function to stop listening. * * @example * ```typescript * // Subscribe to all Task changes - * const unsubscribe = base44.entities.Task.subscribe((event) => { + * const unsubscribe = await base44.entities.Task.subscribe((event) => { * console.log(`Task ${event.id} was ${event.type}d:`, event.data); * }); * @@ -310,7 +310,7 @@ export interface EntityHandler { * unsubscribe(); * ``` */ - subscribe(callback: RealtimeCallback): Subscription; + subscribe(callback: RealtimeCallback): Promise; } /** From 0cb501e1cf64c2a1d668c2177c02c706b8b41631 Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 15:38:37 +0200 Subject: [PATCH 10/14] add tests --- tests/unit/entities-subscribe.test.ts | 209 ++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/unit/entities-subscribe.test.ts diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts new file mode 100644 index 0000000..66d62de --- /dev/null +++ b/tests/unit/entities-subscribe.test.ts @@ -0,0 +1,209 @@ +import { describe, test, expect, vi } from "vitest"; +import { createEntitiesModule } from "../../src/modules/entities.ts"; + +describe("Entities Module - subscribe()", () => { + const appId = "test-app-id"; + + // Helper to create a mock socket + function createMockSocket() { + const listeners: Record = {}; + return { + subscribeToRoom: vi.fn((room: string, handlers: any) => { + listeners[room] = handlers; + // Return unsubscribe function + return () => { + delete listeners[room]; + }; + }), + // Helper to simulate incoming messages + _simulateMessage: (room: string, msg: any) => { + listeners[room]?.update_model?.(msg); + }, + _getListeners: () => listeners, + }; + } + + // Helper to create a mock axios instance + function createMockAxios() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + } + + test("subscribe() should return a Promise that resolves to an unsubscribe function", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const unsubscribe = await entities.Todo.subscribe(callback); + + expect(typeof unsubscribe).toBe("function"); + expect(mockSocket.subscribeToRoom).toHaveBeenCalledWith( + `entities:${appId}:Todo`, + expect.any(Object) + ); + }); + + test("subscribe() should call callback when update_model event is received", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + await entities.Todo.subscribe(callback); + + // Simulate an incoming message + const messageData = JSON.stringify({ + type: "create", + data: { id: "123", title: "New Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }); + + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: messageData, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + type: "create", + data: { id: "123", title: "New Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }); + }); + + test("subscribe() should handle update and delete events", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + await entities.Todo.subscribe(callback); + + // Test update event + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "update", + data: { id: "123", title: "Updated Todo" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "update" }) + ); + + // Test delete event + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "delete", + data: { id: "123" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "delete" }) + ); + expect(callback).toHaveBeenCalledTimes(2); + }); + + test("subscribe() unsubscribe function should stop receiving events", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const unsubscribe = await entities.Todo.subscribe(callback); + + // Simulate a message before unsubscribing + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: {}, + id: "1", + timestamp: "", + }), + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // Unsubscribe + unsubscribe(); + + // Simulate another message after unsubscribing + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: {}, + id: "2", + timestamp: "", + }), + }); + + // Callback should not have been called again + expect(callback).toHaveBeenCalledTimes(1); + }); + + test("subscribe() should not call callback for invalid JSON messages", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const callback = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await entities.Todo.subscribe(callback); + + // Simulate an invalid JSON message + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: "invalid json {{{", + }); + + expect(callback).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "[Base44 SDK] Failed to parse realtime message:", + expect.any(Error) + ); + + warnSpy.mockRestore(); + }); +}); From 84276352b872ab8e82e3802eb0abc6794c833432 Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 15:42:03 +0200 Subject: [PATCH 11/14] lint fix --- src/modules/entities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 9e17c3a..b131463 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -7,7 +7,7 @@ import { RealtimeEventType, Subscription, } from "./entities.types"; -import { RoomsSocket } from "../utils/socket-utils"; +import { RoomsSocket } from "../utils/socket-utils.js"; /** * Configuration for the entities module. From a98e633b0fc6fd46312f75a85387a77715d85ad4 Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 15:50:56 +0200 Subject: [PATCH 12/14] make it so callback that throws doesn't crash --- src/modules/entities.ts | 6 +++- tests/unit/entities-subscribe.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index b131463..1176be7 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -210,7 +210,11 @@ function createEntityHandler( return; } - callback(event); + try { + callback(event); + } catch (error) { + console.error("[Base44 SDK] Subscription callback error:", error); + } }, }); diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index 66d62de..aee4caa 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -206,4 +206,48 @@ describe("Entities Module - subscribe()", () => { warnSpy.mockRestore(); }); + + test("subscribe() should catch and log errors thrown by callback", async () => { + const mockSocket = createMockSocket(); + const mockAxios = createMockAxios(); + + const entities = createEntitiesModule({ + axios: mockAxios as any, + appId, + getSocket: () => mockSocket as any, + }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // Callback that throws an error + const throwingCallback = vi.fn(() => { + throw new Error("Callback error!"); + }); + + await entities.Todo.subscribe(throwingCallback); + + // Simulate a message - this should NOT throw, but log the error + expect(() => { + mockSocket._simulateMessage(`entities:${appId}:Todo`, { + room: `entities:${appId}:Todo`, + data: JSON.stringify({ + type: "create", + data: { id: "123" }, + id: "123", + timestamp: "2024-01-01T00:00:00.000Z", + }), + }); + }).not.toThrow(); + + // The callback should have been called + expect(throwingCallback).toHaveBeenCalledTimes(1); + + // The error should have been logged + expect(errorSpy).toHaveBeenCalledWith( + "[Base44 SDK] Subscription callback error:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + }); }); From 3104d4456c4272f3fcee655b8921d04dd967bf54 Mon Sep 17 00:00:00 2001 From: johnny Date: Wed, 7 Jan 2026 16:20:42 +0200 Subject: [PATCH 13/14] revert making subscribe async --- src/modules/entities.ts | 2 +- src/modules/entities.types.ts | 6 +++--- tests/unit/entities-subscribe.test.ts | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 1176be7..47600a1 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -198,7 +198,7 @@ function createEntityHandler( }, // Subscribe to realtime updates - async subscribe(callback: RealtimeCallback): Promise { + subscribe(callback: RealtimeCallback): Subscription { const room = `entities:${appId}:${entityName}`; // Get the socket and subscribe to the room diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 81ca61f..2251244 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -297,12 +297,12 @@ export interface EntityHandler { * Receives notifications whenever any record is created, updated, or deleted. * * @param callback - Function called when an entity changes. - * @returns Promise resolving to an unsubscribe function to stop listening. + * @returns Unsubscribe function to stop listening. * * @example * ```typescript * // Subscribe to all Task changes - * const unsubscribe = await base44.entities.Task.subscribe((event) => { + * const unsubscribe = base44.entities.Task.subscribe((event) => { * console.log(`Task ${event.id} was ${event.type}d:`, event.data); * }); * @@ -310,7 +310,7 @@ export interface EntityHandler { * unsubscribe(); * ``` */ - subscribe(callback: RealtimeCallback): Promise; + subscribe(callback: RealtimeCallback): Subscription; } /** diff --git a/tests/unit/entities-subscribe.test.ts b/tests/unit/entities-subscribe.test.ts index aee4caa..dc9ffbf 100644 --- a/tests/unit/entities-subscribe.test.ts +++ b/tests/unit/entities-subscribe.test.ts @@ -33,7 +33,7 @@ describe("Entities Module - subscribe()", () => { }; } - test("subscribe() should return a Promise that resolves to an unsubscribe function", async () => { + test("subscribe() should return an unsubscribe function", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -44,7 +44,7 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); - const unsubscribe = await entities.Todo.subscribe(callback); + const unsubscribe = entities.Todo.subscribe(callback); expect(typeof unsubscribe).toBe("function"); expect(mockSocket.subscribeToRoom).toHaveBeenCalledWith( @@ -53,7 +53,7 @@ describe("Entities Module - subscribe()", () => { ); }); - test("subscribe() should call callback when update_model event is received", async () => { + test("subscribe() should call callback when update_model event is received", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -64,7 +64,7 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); - await entities.Todo.subscribe(callback); + entities.Todo.subscribe(callback); // Simulate an incoming message const messageData = JSON.stringify({ @@ -88,7 +88,7 @@ describe("Entities Module - subscribe()", () => { }); }); - test("subscribe() should handle update and delete events", async () => { + test("subscribe() should handle update and delete events", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -99,7 +99,7 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); - await entities.Todo.subscribe(callback); + entities.Todo.subscribe(callback); // Test update event mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -133,7 +133,7 @@ describe("Entities Module - subscribe()", () => { expect(callback).toHaveBeenCalledTimes(2); }); - test("subscribe() unsubscribe function should stop receiving events", async () => { + test("subscribe() unsubscribe function should stop receiving events", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -144,7 +144,7 @@ describe("Entities Module - subscribe()", () => { }); const callback = vi.fn(); - const unsubscribe = await entities.Todo.subscribe(callback); + const unsubscribe = entities.Todo.subscribe(callback); // Simulate a message before unsubscribing mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -177,7 +177,7 @@ describe("Entities Module - subscribe()", () => { expect(callback).toHaveBeenCalledTimes(1); }); - test("subscribe() should not call callback for invalid JSON messages", async () => { + test("subscribe() should not call callback for invalid JSON messages", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -190,7 +190,7 @@ describe("Entities Module - subscribe()", () => { const callback = vi.fn(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await entities.Todo.subscribe(callback); + entities.Todo.subscribe(callback); // Simulate an invalid JSON message mockSocket._simulateMessage(`entities:${appId}:Todo`, { @@ -207,7 +207,7 @@ describe("Entities Module - subscribe()", () => { warnSpy.mockRestore(); }); - test("subscribe() should catch and log errors thrown by callback", async () => { + test("subscribe() should catch and log errors thrown by callback", () => { const mockSocket = createMockSocket(); const mockAxios = createMockAxios(); @@ -224,7 +224,7 @@ describe("Entities Module - subscribe()", () => { throw new Error("Callback error!"); }); - await entities.Todo.subscribe(throwingCallback); + entities.Todo.subscribe(throwingCallback); // Simulate a message - this should NOT throw, but log the error expect(() => { From 0ab4dbc362e7f82d34eb76742b91d1640d982541 Mon Sep 17 00:00:00 2001 From: johnny Date: Thu, 8 Jan 2026 08:47:13 +0200 Subject: [PATCH 14/14] remove backward compatibility for internal impl --- src/modules/entities.ts | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 47600a1..6ffb179 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -28,40 +28,7 @@ export interface EntitiesModuleConfig { */ export function createEntitiesModule( config: EntitiesModuleConfig -): EntitiesModule; - -/** - * Creates the entities module for the Base44 SDK. - * - * @param axios - Axios instance - * @param appId - Application ID - * @returns Entities module with dynamic entity access - * @internal - * @deprecated Use the config object overload instead - */ -export function createEntitiesModule( - axios: AxiosInstance, - appId: string -): EntitiesModule; - -export function createEntitiesModule( - configOrAxios: EntitiesModuleConfig | AxiosInstance, - appIdArg?: string ): EntitiesModule { - // Handle both old and new signatures for backwards compatibility - const config: EntitiesModuleConfig = - "axios" in configOrAxios - ? configOrAxios - : { - axios: configOrAxios, - appId: appIdArg!, - getSocket: () => { - throw new Error( - "Realtime subscriptions are not available. Please update your client configuration." - ); - }, - }; - const { axios, appId, getSocket } = config; // Using Proxy to dynamically handle entity names return new Proxy(