mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
Server-side: - sync_changes gains a nullable space_id TEXT column + partial index on (user_id, space_id, app_id, created_at) WHERE space_id IS NOT NULL. - RecordChange takes spaceID as a first-class parameter; *string so empty strings land as real SQL NULL and the partial index skips them. - ChangeRow + all three SELECTs (GetChangesSince, GetAllChangesSince, StreamAllUserChanges) propagate space_id through to clients. - changeFromRow surfaces SpaceID on the wire Change shape. - New extractSpaceID helper reads the incoming payload — prefers top- level spaceId, falls back to data.spaceId (inserts) or fields.spaceId.value (updates). Tolerates pre-v28 clients. - 6 Go tests cover the helper + round-trip. Client-side: - PendingChange gains an optional spaceId. - Dexie creating hook stamps spaceId from the active record onto the pending-change row (already set by the v28 scope hook). - Dexie updating hook reads spaceId from the pre-update record and stamps it on the pending-change so updates carry space context even though spaceId itself is immutable and never in `fields`. - buildChangeset forwards spaceId to the server. Explicitly NOT in scope this pass: - RLS remains user_id-scoped; multi-member shared-space reads need a second policy that joins against auth.members. Follow-up once shared spaces are actually used — today everything is personal. - Subscription fan-out is still per-user; fan-out to all members of a shared space is part of the same follow-up. Go tests: 6/6 pass. Web type-check clean (0 errors across 7139 files). Plan: docs/plans/spaces-foundation.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
2 KiB
Go
84 lines
2 KiB
Go
package sync
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/mana/mana-sync/internal/store"
|
|
)
|
|
|
|
func TestExtractSpaceID_TopLevelWins(t *testing.T) {
|
|
got := extractSpaceID(Change{
|
|
SpaceID: "space-top",
|
|
Data: map[string]any{"spaceId": "space-data"},
|
|
Fields: map[string]*FieldChange{"spaceId": {Value: "space-field"}},
|
|
})
|
|
if got != "space-top" {
|
|
t.Fatalf("want space-top, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractSpaceID_FallsBackToData(t *testing.T) {
|
|
got := extractSpaceID(Change{
|
|
Data: map[string]any{"spaceId": "space-from-data"},
|
|
})
|
|
if got != "space-from-data" {
|
|
t.Fatalf("want space-from-data, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractSpaceID_FallsBackToFields(t *testing.T) {
|
|
got := extractSpaceID(Change{
|
|
Fields: map[string]*FieldChange{
|
|
"spaceId": {Value: "space-from-fields"},
|
|
},
|
|
})
|
|
if got != "space-from-fields" {
|
|
t.Fatalf("want space-from-fields, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractSpaceID_EmptyWhenMissing(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
c Change
|
|
}{
|
|
{"nothing", Change{}},
|
|
{"empty data", Change{Data: map[string]any{}}},
|
|
{"data non-string", Change{Data: map[string]any{"spaceId": 42}}},
|
|
{"fields non-string", Change{Fields: map[string]*FieldChange{"spaceId": {Value: nil}}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := extractSpaceID(tc.c); got != "" {
|
|
t.Fatalf("want empty, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChangeFromRow_PropagatesSpaceID(t *testing.T) {
|
|
row := store.ChangeRow{
|
|
ID: "evt-1",
|
|
TableName: "tasks",
|
|
RecordID: "task-1",
|
|
Op: "insert",
|
|
SpaceID: "org-edisconet",
|
|
}
|
|
got := changeFromRow(row)
|
|
if got.SpaceID != "org-edisconet" {
|
|
t.Fatalf("want space id to round-trip, got %q", got.SpaceID)
|
|
}
|
|
}
|
|
|
|
func TestChangeFromRow_EmptySpaceIDStaysEmpty(t *testing.T) {
|
|
row := store.ChangeRow{
|
|
ID: "evt-2",
|
|
TableName: "tasks",
|
|
RecordID: "task-2",
|
|
Op: "insert",
|
|
}
|
|
got := changeFromRow(row)
|
|
if got.SpaceID != "" {
|
|
t.Fatalf("want empty space id, got %q", got.SpaceID)
|
|
}
|
|
}
|