Files
llink/go/cmd/memberreconciler/main.go
T
Arjun Patel d262f734f0 Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
2026-05-18 12:44:31 -07:00

134 lines
3.1 KiB
Go

// memberreconciler reconciles the Firestore membership mirror
// (humans/{humanId}.networks) against the authoritative Postgres
// network_members table. Safe to run on a cron — only humans whose mirrored
// set differs from Postgres are written, so a steady-state run is nearly free.
package main
import (
"context"
"log/slog"
"os"
"slices"
"time"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils"
"google.golang.org/api/iterator"
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fs, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
slog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer fs.Close()
humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool())
started := time.Now()
written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc)
if err != nil {
slog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started))
os.Exit(1)
}
slog.Info("reconciliation complete",
"humans_scanned", scanned,
"humans_written", written,
"elapsed", time.Since(started),
)
}
func reconcile(
ctx context.Context,
fs *firestore.Client,
humanSvc human.Service,
networkReader network.Reader,
) (written, scanned int, err error) {
humans, err := humanSvc.ListAll(ctx)
if err != nil {
return 0, 0, err
}
memberships, err := networkReader.ListAllMemberships(ctx)
if err != nil {
return 0, 0, err
}
current, err := snapshotMirror(ctx, fs)
if err != nil {
return 0, 0, err
}
bulk := fs.BulkWriter(ctx)
for _, h := range humans {
scanned++
desired := memberships[h.ID]
if desired == nil {
desired = []string{}
}
if sameSet(current[h.ID], desired) {
continue
}
if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{
"networks": desired,
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll); err != nil {
return written, scanned, err
}
written++
}
bulk.End()
return written, scanned, nil
}
// One iterator, N billed reads — returns humanId → mirrored networks.
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
out := map[string][]string{}
iter := fs.Collection("humans").Documents(ctx)
defer iter.Stop()
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, err
}
var data struct {
Networks []string `firestore:"networks"`
}
if err := doc.DataTo(&data); err != nil {
slog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err)
continue
}
out[doc.Ref.ID] = data.Networks
}
return out, nil
}
// Set equality (order- and duplicate-insensitive); Firestore array ops don't
// preserve order.
func sameSet(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
ac := slices.Clone(a)
bc := slices.Clone(b)
slices.Sort(ac)
slices.Sort(bc)
ac = slices.Compact(ac)
bc = slices.Compact(bc)
return slices.Equal(ac, bc)
}