261 lines
8.6 KiB
Go
261 lines
8.6 KiB
Go
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
|
|
}
|