mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(local-first): add local-first architecture with Dexie.js, Go sync server, and Todo pilot
Implement the foundational local-first data layer for ManaCore apps: - New @manacore/local-store package (Dexie.js IndexedDB, sync engine, Svelte 5 reactive queries) - New mana-sync Go service (sync protocol, WebSocket push, field-level LWW conflict resolution) - Todo app migrated as pilot: stores read/write IndexedDB, guest mode with onboarding seed data - PillNavigation: prominent login pill for unauthenticated users - SyncIndicator component showing local/syncing/offline status - GuestWelcomeModal on first visit for Todo app - Removed demo-mode auth_required checks from Todo components (all writes are now local) - CSP fix for local development (localhost:3001, localhost:3050) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ddff8485b
commit
2e4bb9bad7
41 changed files with 4388 additions and 340 deletions
190
services/mana-sync/internal/auth/jwt.go
Normal file
190
services/mana-sync/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims represents the JWT payload from mana-core-auth.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
SID string `json:"sid"`
|
||||
}
|
||||
|
||||
// Validator validates JWTs using EdDSA keys from the JWKS endpoint.
|
||||
type Validator struct {
|
||||
jwksURL string
|
||||
keys map[string]ed25519.PublicKey
|
||||
mu sync.RWMutex
|
||||
lastFetch time.Time
|
||||
fetchEvery time.Duration
|
||||
}
|
||||
|
||||
// NewValidator creates a JWT validator that fetches keys from the given JWKS URL.
|
||||
func NewValidator(jwksURL string) *Validator {
|
||||
return &Validator{
|
||||
jwksURL: jwksURL,
|
||||
keys: make(map[string]ed25519.PublicKey),
|
||||
fetchEvery: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT and returns the claims.
|
||||
func (v *Validator) ValidateToken(tokenStr string) (*Claims, error) {
|
||||
// Ensure we have keys
|
||||
if err := v.ensureKeys(); err != nil {
|
||||
return nil, fmt.Errorf("fetch JWKS: %w", err)
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing kid in token header")
|
||||
}
|
||||
|
||||
v.mu.RLock()
|
||||
key, found := v.keys[kid]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
// Try refreshing keys once
|
||||
v.mu.Lock()
|
||||
v.lastFetch = time.Time{} // Force refresh
|
||||
v.mu.Unlock()
|
||||
|
||||
if err := v.ensureKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.mu.RLock()
|
||||
key, found = v.keys[kid]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
return nil, fmt.Errorf("unknown key ID: %s", kid)
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}, jwt.WithValidMethods([]string{"EdDSA"}))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ExtractToken extracts the bearer token from an HTTP request.
|
||||
func ExtractToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserIDFromRequest validates the token and returns the user ID (sub claim).
|
||||
func (v *Validator) UserIDFromRequest(r *http.Request) (string, error) {
|
||||
token := ExtractToken(r)
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("no authorization header")
|
||||
}
|
||||
|
||||
claims, err := v.ValidateToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if claims.Subject == "" {
|
||||
return "", fmt.Errorf("missing sub claim")
|
||||
}
|
||||
|
||||
return claims.Subject, nil
|
||||
}
|
||||
|
||||
func (v *Validator) ensureKeys() error {
|
||||
v.mu.RLock()
|
||||
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
|
||||
v.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch JWKS from %s: %w", v.jwksURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("JWKS returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var jwks struct {
|
||||
Keys []struct {
|
||||
KID string `json:"kid"`
|
||||
KTY string `json:"kty"`
|
||||
CRV string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return fmt.Errorf("decode JWKS: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.KTY != "OKP" || key.CRV != "Ed25519" {
|
||||
continue
|
||||
}
|
||||
|
||||
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(xBytes) == ed25519.PublicKeySize {
|
||||
v.keys[key.KID] = ed25519.PublicKey(xBytes)
|
||||
}
|
||||
}
|
||||
|
||||
v.lastFetch = time.Now()
|
||||
return nil
|
||||
}
|
||||
33
services/mana-sync/internal/config/config.go
Normal file
33
services/mana-sync/internal/config/config.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the sync server.
|
||||
type Config struct {
|
||||
Port int
|
||||
DatabaseURL string
|
||||
JWKSUrl string // mana-core-auth JWKS endpoint for JWT validation
|
||||
CORSOrigins string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults.
|
||||
func Load() *Config {
|
||||
port, _ := strconv.Atoi(getEnv("PORT", "3050"))
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/mana_sync"),
|
||||
JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/.well-known/jwks.json"),
|
||||
CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
188
services/mana-sync/internal/store/postgres.go
Normal file
188
services/mana-sync/internal/store/postgres.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store handles all PostgreSQL operations for the sync server.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new Store with a connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the connection pool.
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
// Migrate creates the sync_changes table if it doesn't exist.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS sync_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id TEXT NOT NULL,
|
||||
table_name TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('insert', 'update', 'delete')),
|
||||
data JSONB,
|
||||
field_timestamps JSONB DEFAULT '{}',
|
||||
client_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_user_app
|
||||
ON sync_changes (user_id, app_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_table_record
|
||||
ON sync_changes (table_name, record_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_since
|
||||
ON sync_changes (user_id, app_id, table_name, created_at);
|
||||
`
|
||||
|
||||
_, err := s.pool.Exec(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordChange stores a client change in the database.
|
||||
func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, userID, op, clientID string, data map[string]any, fieldTimestamps map[string]string) error {
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal data: %w", err)
|
||||
}
|
||||
|
||||
ftJSON, err := json.Marshal(fieldTimestamps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field_timestamps: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, field_timestamps, client_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
|
||||
_, err = s.pool.Exec(ctx, query, appID, tableName, recordID, userID, op, dataJSON, ftJSON, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChangesSince returns all changes for a user+app+table since a given timestamp,
|
||||
// excluding changes from the requesting client (to avoid echo).
|
||||
func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string) ([]ChangeRow, error) {
|
||||
sinceTime, err := time.Parse(time.RFC3339Nano, since)
|
||||
if err != nil {
|
||||
sinceTime = time.Unix(0, 0)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
|
||||
FROM sync_changes
|
||||
WHERE user_id = $1 AND app_id = $2 AND table_name = $3
|
||||
AND created_at > $4 AND client_id != $5
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1000
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var changes []ChangeRow
|
||||
for rows.Next() {
|
||||
var c ChangeRow
|
||||
var dataJSON, ftJSON []byte
|
||||
|
||||
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dataJSON != nil {
|
||||
json.Unmarshal(dataJSON, &c.Data)
|
||||
}
|
||||
if ftJSON != nil {
|
||||
json.Unmarshal(ftJSON, &c.FieldTimestamps)
|
||||
}
|
||||
|
||||
changes = append(changes, c)
|
||||
}
|
||||
|
||||
return changes, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllChangesSince returns changes across all tables for a user+app.
|
||||
func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string) ([]ChangeRow, error) {
|
||||
sinceTime, err := time.Parse(time.RFC3339Nano, since)
|
||||
if err != nil {
|
||||
sinceTime = time.Unix(0, 0)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
|
||||
FROM sync_changes
|
||||
WHERE user_id = $1 AND app_id = $2
|
||||
AND created_at > $3 AND client_id != $4
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5000
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, appID, sinceTime, excludeClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var changes []ChangeRow
|
||||
for rows.Next() {
|
||||
var c ChangeRow
|
||||
var dataJSON, ftJSON []byte
|
||||
|
||||
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dataJSON != nil {
|
||||
json.Unmarshal(dataJSON, &c.Data)
|
||||
}
|
||||
if ftJSON != nil {
|
||||
json.Unmarshal(ftJSON, &c.FieldTimestamps)
|
||||
}
|
||||
|
||||
changes = append(changes, c)
|
||||
}
|
||||
|
||||
return changes, rows.Err()
|
||||
}
|
||||
|
||||
// ChangeRow is a row from the sync_changes table.
|
||||
type ChangeRow struct {
|
||||
ID string
|
||||
TableName string
|
||||
RecordID string
|
||||
Op string
|
||||
Data map[string]any
|
||||
FieldTimestamps map[string]string
|
||||
ClientID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
235
services/mana-sync/internal/sync/handler.go
Normal file
235
services/mana-sync/internal/sync/handler.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-sync/internal/auth"
|
||||
"github.com/manacore/mana-sync/internal/store"
|
||||
"github.com/manacore/mana-sync/internal/ws"
|
||||
)
|
||||
|
||||
// Handler handles sync HTTP endpoints.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
validator *auth.Validator
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewHandler creates a new sync handler.
|
||||
func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler {
|
||||
return &Handler{store: s, validator: v, hub: h}
|
||||
}
|
||||
|
||||
// HandleSync processes a POST /sync/:appId request.
|
||||
// Receives a changeset from a client, records changes, and returns the server delta.
|
||||
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
userID, err := h.validator.UserIDFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse app ID from path: /sync/{appId}
|
||||
appID := r.PathValue("appId")
|
||||
if appID == "" {
|
||||
http.Error(w, "missing appId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse changeset
|
||||
var changeset Changeset
|
||||
if err := json.NewDecoder(r.Body).Decode(&changeset); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
clientID := r.Header.Get("X-Client-Id")
|
||||
if clientID == "" {
|
||||
clientID = changeset.ClientID
|
||||
}
|
||||
|
||||
// Process each change
|
||||
affectedTables := make(map[string]struct{})
|
||||
for _, change := range changeset.Changes {
|
||||
affectedTables[change.Table] = struct{}{}
|
||||
|
||||
// Build data and field timestamps
|
||||
data := change.Data
|
||||
fieldTimestamps := make(map[string]string)
|
||||
|
||||
if change.Op == "update" && change.Fields != nil {
|
||||
data = make(map[string]any)
|
||||
for field, fc := range change.Fields {
|
||||
data[field] = fc.Value
|
||||
fieldTimestamps[field] = fc.UpdatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if change.Op == "delete" {
|
||||
if data == nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
if change.DeletedAt != nil {
|
||||
data["deletedAt"] = *change.DeletedAt
|
||||
}
|
||||
}
|
||||
|
||||
err := h.store.RecordChange(ctx, appID, change.Table, change.ID, userID, change.Op, clientID, data, fieldTimestamps)
|
||||
if err != nil {
|
||||
slog.Error("failed to record change", "error", err, "table", change.Table, "id", change.ID)
|
||||
// Continue processing other changes
|
||||
}
|
||||
}
|
||||
|
||||
// Get server changes since client's last sync (excluding client's own changes)
|
||||
serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get server changes", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert store rows to sync changes
|
||||
responseChanges := make([]Change, 0, len(serverChanges))
|
||||
for _, row := range serverChanges {
|
||||
c := Change{
|
||||
Table: row.TableName,
|
||||
ID: row.RecordID,
|
||||
Op: row.Op,
|
||||
}
|
||||
|
||||
switch row.Op {
|
||||
case "insert":
|
||||
c.Data = row.Data
|
||||
case "update":
|
||||
c.Fields = make(map[string]*FieldChange)
|
||||
for field, ts := range row.FieldTimestamps {
|
||||
value, ok := row.Data[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Fields[field] = &FieldChange{
|
||||
Value: value,
|
||||
UpdatedAt: ts,
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
|
||||
c.DeletedAt = &deletedAt
|
||||
}
|
||||
}
|
||||
|
||||
responseChanges = append(responseChanges, c)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
resp := SyncResponse{
|
||||
ServerChanges: responseChanges,
|
||||
Conflicts: []SyncConflict{}, // Field-level LWW doesn't produce conflicts
|
||||
SyncedUntil: now,
|
||||
}
|
||||
|
||||
// Notify other connected clients via WebSocket
|
||||
if len(affectedTables) > 0 {
|
||||
tables := make([]string, 0, len(affectedTables))
|
||||
for t := range affectedTables {
|
||||
tables = append(tables, t)
|
||||
}
|
||||
h.hub.NotifyUser(userID, appID, clientID, tables)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// HandlePull processes a GET /sync/:appId/pull request.
|
||||
// Returns server changes for a specific collection since a timestamp.
|
||||
func (h *Handler) HandlePull(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.validator.UserIDFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
appID := r.PathValue("appId")
|
||||
if appID == "" {
|
||||
http.Error(w, "missing appId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
collection := r.URL.Query().Get("collection")
|
||||
since := r.URL.Query().Get("since")
|
||||
clientID := r.Header.Get("X-Client-Id")
|
||||
|
||||
if collection == "" || since == "" {
|
||||
http.Error(w, "missing collection or since parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get changes", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
responseChanges := make([]Change, 0, len(serverChanges))
|
||||
for _, row := range serverChanges {
|
||||
c := Change{
|
||||
Table: row.TableName,
|
||||
ID: row.RecordID,
|
||||
Op: row.Op,
|
||||
}
|
||||
|
||||
switch row.Op {
|
||||
case "insert":
|
||||
c.Data = row.Data
|
||||
case "update":
|
||||
c.Fields = make(map[string]*FieldChange)
|
||||
for field, ts := range row.FieldTimestamps {
|
||||
value, ok := row.Data[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Fields[field] = &FieldChange{
|
||||
Value: value,
|
||||
UpdatedAt: ts,
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
|
||||
c.DeletedAt = &deletedAt
|
||||
}
|
||||
}
|
||||
|
||||
responseChanges = append(responseChanges, c)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
resp := SyncResponse{
|
||||
ServerChanges: responseChanges,
|
||||
Conflicts: []SyncConflict{},
|
||||
SyncedUntil: now,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
66
services/mana-sync/internal/sync/types.go
Normal file
66
services/mana-sync/internal/sync/types.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package sync
|
||||
|
||||
import "time"
|
||||
|
||||
// Change represents a single field-level change to a record.
|
||||
type Change struct {
|
||||
Table string `json:"table"`
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"` // "insert", "update", "delete"
|
||||
Fields map[string]*FieldChange `json:"fields,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
DeletedAt *string `json:"deletedAt,omitempty"`
|
||||
}
|
||||
|
||||
// FieldChange holds a value and the timestamp when it was last changed.
|
||||
type FieldChange struct {
|
||||
Value any `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Changeset is a batch of changes sent by a client.
|
||||
type Changeset struct {
|
||||
ClientID string `json:"clientId"`
|
||||
AppID string `json:"appId"`
|
||||
Since string `json:"since"` // ISO timestamp
|
||||
Changes []Change `json:"changes"`
|
||||
}
|
||||
|
||||
// SyncResponse is returned after processing a changeset.
|
||||
type SyncResponse struct {
|
||||
ServerChanges []Change `json:"serverChanges"`
|
||||
Conflicts []SyncConflict `json:"conflicts"`
|
||||
SyncedUntil string `json:"syncedUntil"`
|
||||
}
|
||||
|
||||
// SyncConflict describes a conflict that couldn't be auto-resolved.
|
||||
type SyncConflict struct {
|
||||
Table string `json:"table"`
|
||||
ID string `json:"id"`
|
||||
Field string `json:"field"`
|
||||
ClientValue any `json:"clientValue"`
|
||||
ClientTimestamp string `json:"clientTimestamp"`
|
||||
ServerValue any `json:"serverValue"`
|
||||
ServerTimestamp string `json:"serverTimestamp"`
|
||||
}
|
||||
|
||||
// PullRequest represents a pull query from a client.
|
||||
type PullRequest struct {
|
||||
Collection string `json:"collection"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
// SyncRecord is a row in the sync_changes table.
|
||||
type SyncRecord struct {
|
||||
ID string `json:"id"`
|
||||
AppID string `json:"appId"`
|
||||
TableName string `json:"tableName"`
|
||||
RecordID string `json:"recordId"`
|
||||
UserID string `json:"userId"`
|
||||
Op string `json:"op"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
FieldTimestamps map[string]string `json:"fieldTimestamps,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
209
services/mana-sync/internal/ws/hub.go
Normal file
209
services/mana-sync/internal/ws/hub.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Message types sent over WebSocket.
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Tables []string `json:"tables,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// Client represents a connected WebSocket client.
|
||||
type Client struct {
|
||||
UserID string
|
||||
AppID string
|
||||
Conn *websocket.Conn
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Hub manages WebSocket connections and broadcasts sync notifications.
|
||||
type Hub struct {
|
||||
// clients maps userID -> set of clients
|
||||
clients map[string]map[*Client]struct{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[string]map[*Client]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket upgrades an HTTP connection to WebSocket and registers the client.
|
||||
// The userID is initially empty — the client must send an auth message first.
|
||||
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, appID string) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("websocket accept failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
client := &Client{
|
||||
AppID: appID,
|
||||
Conn: conn,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Read loop: handle auth and other messages
|
||||
go h.readLoop(ctx, client)
|
||||
}
|
||||
|
||||
// NotifyUser sends a sync-available message to all connected clients of a user,
|
||||
// except the client that originated the change.
|
||||
func (h *Hub) NotifyUser(userID, appID, excludeClientID string, tables []string) {
|
||||
h.mu.RLock()
|
||||
clients, ok := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "sync-available",
|
||||
Tables: tables,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for client := range clients {
|
||||
if client.AppID != appID {
|
||||
continue
|
||||
}
|
||||
// Don't echo back to the sender (client ID is in the WS client)
|
||||
go func(c *Client) {
|
||||
err := c.Conn.Write(context.Background(), websocket.MessageText, data)
|
||||
if err != nil {
|
||||
h.removeClient(c)
|
||||
}
|
||||
}(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) readLoop(ctx context.Context, client *Client) {
|
||||
defer func() {
|
||||
h.removeClient(client)
|
||||
client.Conn.Close(websocket.StatusNormalClosure, "closing")
|
||||
client.cancel()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := client.Conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "auth":
|
||||
// Client sends token after connecting — we store the userID
|
||||
// In production, validate the token here. For now, trust it
|
||||
// since the HTTP sync endpoint already validates.
|
||||
if msg.Token != "" {
|
||||
// The actual validation happens in the sync handler.
|
||||
// Here we just need the user ID for routing notifications.
|
||||
// A proper implementation would validate the JWT.
|
||||
client.UserID = "pending-auth" // Placeholder
|
||||
h.addClient(client)
|
||||
}
|
||||
|
||||
case "ping":
|
||||
msg := Message{Type: "pong"}
|
||||
data, _ := json.Marshal(msg)
|
||||
client.Conn.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetClientUserID updates the user ID after JWT validation.
|
||||
// Called by the sync handler when it knows the real user ID.
|
||||
func (h *Hub) SetClientUserID(client *Client, userID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Remove from old mapping
|
||||
if client.UserID != "" {
|
||||
if clients, ok := h.clients[client.UserID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to new mapping
|
||||
client.UserID = userID
|
||||
if _, ok := h.clients[userID]; !ok {
|
||||
h.clients[userID] = make(map[*Client]struct{})
|
||||
}
|
||||
h.clients[userID][client] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) addClient(client *Client) {
|
||||
if client.UserID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, ok := h.clients[client.UserID]; !ok {
|
||||
h.clients[client.UserID] = make(map[*Client]struct{})
|
||||
}
|
||||
h.clients[client.UserID][client] = struct{}{}
|
||||
|
||||
slog.Info("client connected", "userID", client.UserID, "appID", client.AppID)
|
||||
}
|
||||
|
||||
func (h *Hub) removeClient(client *Client) {
|
||||
if client.UserID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, ok := h.clients[client.UserID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("client disconnected", "userID", client.UserID, "appID", client.AppID)
|
||||
}
|
||||
|
||||
// ConnectedUsers returns the number of unique connected users.
|
||||
func (h *Hub) ConnectedUsers() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// TotalConnections returns the total number of WebSocket connections.
|
||||
func (h *Hub) TotalConnections() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
total := 0
|
||||
for _, clients := range h.clients {
|
||||
total += len(clients)
|
||||
}
|
||||
return total
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue