package apis import ( "encoding/json" "fmt" "os" "strings" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/subscriptions" ) type IBridgedClient interface { subscriptions.Client ClientSubscription() *ClientSubscription // IsRemoteClient returns true if the client is connected to current pocketbase instance via WebSocket. // It returns false if the client is connected to other pocketbase instances. IsRemoteClient() bool // BroadcastGoOffline notifies all other pocketbase instances that the client is offline. BroadcastGoOffline() // BroadcastChanges notifies all other pocketbase instances about the client's subscription changes. // It also works as BroadcastGoOnline() when it is called for the first time. BroadcastChanges() // ReceiveChanges receives subscription changes from other pocketbase instances. ReceiveChanges(newSubscription *ClientSubscription, authRecordJson string) } // ClientSubscription is stored in the _realtimeClients table in PostgreSQL. // Every time a new client connects to the pocketbase server, a new row is created. // And other pocketbase servers are notified via Postgres's Listen/Notify feature. type ClientSubscription struct { ClientId string `db:"pk,clientId" json:"clientId"` ChannelId string `db:"channelId" json:"channelId"` Subscriptions []string `db:"subscriptions" json:"subscriptions"` AuthCollectionRef string `db:"authCollectionRef" json:"authCollectionRef"` AuthRecordRef string `db:"authRecordRef" json:"authRecordRef"` UpdatedByChannelId string `db:"updatedByChannelId" json:"updatedByChannelId"` } var _ subscriptions.Client = (*BridgedClient)(nil) var _ IBridgedClient = (*BridgedClient)(nil) // BridgedClient defines a remote subscription client. type BridgedClient struct { subscriptions.Client bridge IRealtimeBridge subscription *ClientSubscription } // NewBridgedClient // - NewBridgedClient(bridge) creates a local client // - NewBridgedClient(bridge, optionalSubscription) creates a remote client func NewBridgedClient(bridge IRealtimeBridge, optionalSubscription ...*ClientSubscription) *BridgedClient { var client subscriptions.Client var subscription *ClientSubscription if len(optionalSubscription) > 0 && optionalSubscription[0] != nil { // remote client (client is connected to other pocketbase instance) client = subscriptions.NewDefaultClient(optionalSubscription[0].ClientId) subscription = optionalSubscription[0] } else { // local client (client is connected to current pocketbase instance) client = subscriptions.NewDefaultClient() subscription = &ClientSubscription{ ClientId: client.Id(), ChannelId: bridge.SelfChannelId(), Subscriptions: []string{}, AuthCollectionRef: "", AuthRecordRef: "", UpdatedByChannelId: "", } } return &BridgedClient{ bridge: bridge, Client: client, subscription: subscription, } } func (r *BridgedClient) BroadcastGoOffline() { // delete and notify _, err := r.bridge.App().DB().NewQuery(` WITH deleted AS ( DELETE FROM "_realtimeClients" WHERE "clientId" = {:clientId} RETURNING * ) SELECT pg_notify('shared_bridge_channel', 'subscription_delete|' || deleted."clientId") FROM deleted; `).Bind(dbx.Params{ "clientId": r.subscription.ClientId, }).Execute() if err != nil { fmt.Fprintln(os.Stderr, "Error deleting subscription:", err) return } } // BroadcastChanges notifies all other pocketbase instances about the client's subscription changes. // If it is called for the first time, it will act as `BroadcastGoOnline()`. // If the client has an auth record, it will send the auth record JSON to other pocketbase instances. func (r *BridgedClient) BroadcastChanges() { if len(r.RawSubscriptions()) == 0 { // treat no subscriptions as offline r.BroadcastGoOffline() return } // 1. clientId should not be changed // 2. channelId should not be changed // 3. update subscriptions r.subscription.Subscriptions = r.RawSubscriptions() // 4. updatedByChannelId is set to the current channelId r.subscription.UpdatedByChannelId = r.bridge.SelfChannelId() // 5. update auth collection and record refs var authSQL string var authParams dbx.Params if record, _ := r.Get(RealtimeClientAuthKey).(*core.Record); record != nil { r.subscription.AuthCollectionRef = record.Collection().Id r.subscription.AuthRecordRef = record.Id authSQL = fmt.Sprintf(`SELECT * FROM {{%s}} WHERE id = {:authRecordRef} LIMIT 1`, record.TableName()) authParams = dbx.Params{ "authRecordRef": record.Id, } } else { r.subscription.AuthCollectionRef = "" r.subscription.AuthRecordRef = "" authSQL = "SELECT WHERE 1=0" // empty rows. } // Update using raw SQL query _, err := r.bridge.App().DB().NewQuery(fmt.Sprintf(` WITH updated AS ( INSERT INTO "_realtimeClients" ( "clientId", "channelId", "subscriptions", "authCollectionRef", "authRecordRef", "updatedByChannelId" ) VALUES ( {:clientId}, {:channelId}, {:subscriptions}, {:authCollectionRef}, {:authRecordRef}, {:updatedByChannelId} ) ON CONFLICT ("clientId") DO UPDATE SET "subscriptions" = EXCLUDED."subscriptions", "authCollectionRef" = EXCLUDED."authCollectionRef", "authRecordRef" = EXCLUDED."authRecordRef", "updatedByChannelId" = EXCLUDED."updatedByChannelId" RETURNING * ), auth AS ( %s ) SELECT pg_notify( 'shared_bridge_channel', CONCAT_WS( '|', 'subscription_upsert', (SELECT row_to_json(u) FROM updated u)::text, COALESCE((SELECT row_to_json(a) FROM auth a)::text, '') ) ); `, authSQL)).Bind(authParams).Bind(dbx.Params{ "clientId": r.subscription.ClientId, "channelId": r.subscription.ChannelId, "subscriptions": r.subscription.Subscriptions, "authCollectionRef": r.subscription.AuthCollectionRef, "authRecordRef": r.subscription.AuthRecordRef, "updatedByChannelId": r.subscription.UpdatedByChannelId, }).Execute() if err != nil { fmt.Fprintln(os.Stderr, "Error updating subscription:", err) return } // other sibling servers will be notified by Postgres Listen/Notify feature } func (r *BridgedClient) ReceiveChanges(newSubscription *ClientSubscription, authRecordJson string) { // 1. clientId should not be changed // 2. channelId should not be changed // 3. update subscriptions r.Client.Unsubscribe() // clear previous subscriptions r.Client.Subscribe(newSubscription.Subscriptions...) // 4. update auth collection and record refs if newSubscription.AuthRecordRef == "" || newSubscription.AuthCollectionRef == "" { r.Client.Unset(RealtimeClientAuthKey) } else { authRecord, err := AuthRecordFromJson(r.bridge.App(), newSubscription.AuthCollectionRef, authRecordJson) if err != nil { fmt.Fprintln(os.Stderr, "Error unmarshalling auth record JSON:", err) return } r.Client.Set(RealtimeClientAuthKey, authRecord) } // 5. updatedByChannelId should not be changed // Finally, update the subscription r.subscription = newSubscription } // Send sends the specified message to the client's channel (if not discarded). func (r *BridgedClient) Send(m subscriptions.Message) { if r.IsDiscarded() { return } if r.IsRemoteClient() { // send to sibling servers r.bridge.SendViaBridge(r.subscription.ChannelId, r.Id(), m) } else { // send to connected clients r.Client.Send(m) } } func (r *BridgedClient) IsRemoteClient() bool { return r.subscription.ChannelId != r.bridge.SelfChannelId() } func (r *BridgedClient) ClientSubscription() *ClientSubscription { return r.subscription } func AuthRecordFromJson(app core.App, authCollectionNameOrId string, authRecordJson string) (*core.Record, error) { if authRecordJson == "" { return nil, nil } collection, err := app.FindCachedCollectionByNameOrId(authCollectionNameOrId) if err != nil { return nil, fmt.Errorf("error finding collection by name or ID: %w", err) } authRecord := core.NewRecord(collection) data := make(map[string]any) _ = json.Unmarshal([]byte(authRecordJson), &data) for _, field := range collection.Fields { fieldName := field.GetName() value, _ := field.PrepareValue(authRecord, data[fieldName]) authRecord.SetRaw(fieldName, value) } return authRecord, nil } func split2(s string, sep string) (string, string, bool) { parts := strings.SplitN(s, sep, 2) if len(parts) != 2 { return "", "", false } return parts[0], parts[1], true } func split3(s string, sep string) (string, string, string, bool) { parts := strings.SplitN(s, sep, 3) if len(parts) != 3 { return "", "", "", false } return parts[0], parts[1], parts[2], true }