feat(spaces): thread space_id through mana-sync protocol + storage

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>
This commit is contained in:
Till JS 2026-04-20 16:53:14 +02:00
parent 9f7d2f24b3
commit e10c2436a6
6 changed files with 205 additions and 11 deletions

View file

@ -882,6 +882,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
data: dataForSync,
actor,
createdAt: now,
spaceId: typeof objRecord.spaceId === 'string' ? (objRecord.spaceId as string) : undefined,
});
trackActivity(appId, tableName, obj.id, 'insert');
trackFirstContent(appId);
@ -937,6 +938,14 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
}
const op = (modifications as Record<string, unknown>).deletedAt ? 'delete' : 'update';
// spaceId is immutable and therefore not in `fields` for updates —
// but the server wants it as a first-class column on every row.
// Read it from the pre-update record so the pending-change row
// carries the right space for routing even when only a title changed.
const existingSpaceId =
typeof (obj as Record<string, unknown>).spaceId === 'string'
? ((obj as Record<string, unknown>).spaceId as string)
: undefined;
trackPendingChange(tableName, {
appId,
collection: tableName,
@ -946,6 +955,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
actor,
deletedAt: (modifications as Record<string, unknown>).deletedAt as string | undefined,
createdAt: now,
spaceId: existingSpaceId,
});
trackActivity(appId, tableName, primKey as string, op);
fireTrigger(appId, tableName, op, modifications as Record<string, unknown>);

View file

@ -100,6 +100,14 @@ interface PendingChange {
deletedAt?: string;
actor?: Actor;
createdAt: string;
/**
* The Space (Better Auth organization id) the record belongs to. Stamped
* on the pending-change row at write time so the server gets it as a
* first-class column even for updates (where it isn't in `fields`
* because it's immutable). Empty string / undefined means "pre-v28
* record" the server tolerates NULL on the column.
*/
spaceId?: string;
}
interface SyncMeta {
@ -1120,6 +1128,7 @@ export function createUnifiedSync(
data: p.data,
deletedAt: p.deletedAt,
actor: p.actor,
spaceId: p.spaceId,
})),
};
}

View file

@ -72,6 +72,15 @@ func (s *Store) Migrate(ctx context.Context) error {
ALTER TABLE sync_changes
ADD COLUMN IF NOT EXISTS actor JSONB;
-- Idempotent add for databases created before the Spaces foundation.
-- Nullable so pre-v28 clients (which don't stamp a spaceId) can
-- keep pushing. The RLS policy is intentionally NOT space-aware
-- yet user_id remains the primary guard. Multi-member scoping
-- for shared spaces will add a second policy in a follow-up.
-- See docs/plans/spaces-foundation.md.
ALTER TABLE sync_changes
ADD COLUMN IF NOT EXISTS space_id TEXT;
CREATE INDEX IF NOT EXISTS idx_sync_changes_user_app
ON sync_changes (user_id, app_id, created_at);
@ -81,6 +90,13 @@ func (s *Store) Migrate(ctx context.Context) error {
CREATE INDEX IF NOT EXISTS idx_sync_changes_since
ON sync_changes (user_id, app_id, table_name, created_at);
-- Fast "all changes for a space since X" queries once shared spaces
-- go live. Safe to create with nullable space_id Postgres partial
-- indexes skip NULLs unless asked otherwise.
CREATE INDEX IF NOT EXISTS idx_sync_changes_user_space_app_since
ON sync_changes (user_id, space_id, app_id, created_at)
WHERE space_id IS NOT NULL;
ALTER TABLE sync_changes ENABLE ROW LEVEL SECURITY;
-- FORCE makes RLS apply even to the table owner so that the application
-- role used by mana-sync cannot bypass policies, regardless of grants.
@ -130,10 +146,15 @@ func (s *Store) withUser(ctx context.Context, userID string, fn func(pgx.Tx) err
// inside an RLS-scoped transaction so the user_id column is double-checked
// against the policy on the way in — a mismatched user_id would fail WITH CHECK.
//
// `spaceID` is the Better Auth organization id the record belongs to.
// Pass empty string for pre-v28 callers; the column is nullable so mixed
// populations of pre- and post-v28 clients are fine. When multi-member
// space RLS lands, empty space_id rows will need a one-off backfill.
//
// `actor` is the opaque JSON blob the webapp stamps on every change (see
// `data/events/actor.ts`). Pass nil for pre-actor callers; the column is
// nullable and cross-device consumers treat a missing actor as `user`.
func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, userID, op, clientID string, data map[string]any, fieldTimestamps map[string]string, schemaVersion int, actor json.RawMessage) error {
func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, userID, spaceID, op, clientID string, data map[string]any, fieldTimestamps map[string]string, schemaVersion int, actor json.RawMessage) error {
if schemaVersion <= 0 {
schemaVersion = 1
}
@ -156,12 +177,20 @@ func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, us
actorJSON = []byte(actor)
}
// pgx interprets a Go empty string as empty, not NULL — use *string so
// an unset space_id lands as a real SQL NULL and the partial index
// skips the row.
var spaceIDParam *string
if spaceID != "" {
spaceIDParam = &spaceID
}
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
query := `
INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, field_timestamps, client_id, schema_version, actor)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO sync_changes (app_id, table_name, record_id, user_id, space_id, op, data, field_timestamps, client_id, schema_version, actor)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err := tx.Exec(ctx, query, appID, tableName, recordID, userID, op, dataJSON, ftJSON, clientID, schemaVersion, actorJSON)
_, err := tx.Exec(ctx, query, appID, tableName, recordID, userID, spaceIDParam, op, dataJSON, ftJSON, clientID, schemaVersion, actorJSON)
return err
})
}
@ -178,7 +207,7 @@ func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, s
var changes []ChangeRow
err = s.withUser(ctx, userID, func(tx pgx.Tx) error {
query := `
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, actor
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor
FROM sync_changes
WHERE user_id = $1 AND app_id = $2 AND table_name = $3
AND created_at > $4 AND client_id != $5
@ -194,11 +223,15 @@ func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, s
for rows.Next() {
var c ChangeRow
var dataJSON, ftJSON, actorJSON []byte
var spaceID *string
if err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &actorJSON); err != nil {
if err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &spaceID, &actorJSON); err != nil {
return err
}
if spaceID != nil {
c.SpaceID = *spaceID
}
if dataJSON != nil {
if err := json.Unmarshal(dataJSON, &c.Data); err != nil {
return fmt.Errorf("unmarshal data for record %s: %w", c.RecordID, err)
@ -230,7 +263,7 @@ func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, ex
var changes []ChangeRow
err = s.withUser(ctx, userID, func(tx pgx.Tx) error {
query := `
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, actor
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor
FROM sync_changes
WHERE user_id = $1 AND app_id = $2
AND created_at > $3 AND client_id != $4
@ -246,11 +279,15 @@ func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, ex
for rows.Next() {
var c ChangeRow
var dataJSON, ftJSON, actorJSON []byte
var spaceID *string
if err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &actorJSON); err != nil {
if err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &spaceID, &actorJSON); err != nil {
return err
}
if spaceID != nil {
c.SpaceID = *spaceID
}
if dataJSON != nil {
if err := json.Unmarshal(dataJSON, &c.Data); err != nil {
return fmt.Errorf("unmarshal data for record %s: %w", c.RecordID, err)
@ -280,7 +317,7 @@ func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, ex
func (s *Store) StreamAllUserChanges(ctx context.Context, userID string, fn func(ChangeRow) error) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
query := `
SELECT id, app_id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, actor
SELECT id, app_id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor
FROM sync_changes
WHERE user_id = $1
ORDER BY created_at ASC, id ASC
@ -294,9 +331,13 @@ func (s *Store) StreamAllUserChanges(ctx context.Context, userID string, fn func
for rows.Next() {
var c ChangeRow
var dataJSON, ftJSON, actorJSON []byte
if err := rows.Scan(&c.ID, &c.AppID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &actorJSON); err != nil {
var spaceID *string
if err := rows.Scan(&c.ID, &c.AppID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt, &c.SchemaVersion, &spaceID, &actorJSON); err != nil {
return fmt.Errorf("scan: %w", err)
}
if spaceID != nil {
c.SpaceID = *spaceID
}
if dataJSON != nil {
if err := json.Unmarshal(dataJSON, &c.Data); err != nil {
return fmt.Errorf("unmarshal data for record %s: %w", c.RecordID, err)
@ -330,6 +371,10 @@ type ChangeRow struct {
ClientID string
CreatedAt time.Time
SchemaVersion int
// SpaceID is empty for pre-v28 rows. Consumers use it to partition
// the reader cache per space; an empty string means "implicit personal"
// until the bootstrap reconciliation fills it in.
SpaceID string
// Actor is nil for rows written by pre-actor clients. Consumers on
// other devices render a missing actor as "user".
Actor json.RawMessage

View file

@ -28,6 +28,38 @@ func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler {
// maxBodySize is the maximum allowed request body (10 MB).
const maxBodySize = 10 * 1024 * 1024
// extractSpaceID pulls the Space id out of an incoming change payload. The
// protocol preference is:
//
// 1. Top-level `spaceId` (v28+ clients stamp it explicitly — cheap to parse).
// 2. `data.spaceId` — present on inserts from pre-protocol clients that only
// send the full record object.
// 3. `fields.spaceId.value` — present on updates that happen to touch the
// scope field (rare; spaceId is marked immutable in the Dexie updating
// hook, so in practice this branch only covers edge cases).
//
// Returns the empty string when none of the above yield a usable value. An
// empty string lands as SQL NULL in the space_id column so partial indexes
// keep skipping legacy rows cleanly.
func extractSpaceID(change Change) string {
if change.SpaceID != "" {
return change.SpaceID
}
if change.Data != nil {
if v, ok := change.Data["spaceId"].(string); ok && v != "" {
return v
}
}
if change.Fields != nil {
if fc, ok := change.Fields["spaceId"]; ok && fc != nil {
if v, ok := fc.Value.(string); ok && v != "" {
return v
}
}
}
return ""
}
// changeFromRow projects a stored sync_changes row onto the wire Change shape.
// Carries eventId + schemaVersion through so clients can dedup on replay and
// route through the migration chain.
@ -42,6 +74,7 @@ func changeFromRow(row store.ChangeRow) Change {
Table: row.TableName,
ID: row.RecordID,
Op: row.Op,
SpaceID: row.SpaceID,
Actor: row.Actor,
}
switch row.Op {
@ -162,7 +195,13 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
if rowSchemaVersion <= 0 {
rowSchemaVersion = schemaVersion
}
err := h.store.RecordChange(ctx, appID, change.Table, change.ID, userID, change.Op, clientID, data, fieldTimestamps, rowSchemaVersion, change.Actor)
// spaceId for this change: prefer the top-level field (post-v28
// clients stamp it explicitly), fall back to data.spaceId for
// inserts and fields.spaceId.value for updates so pre-protocol
// clients still get indexed correctly. Empty string lands as SQL
// NULL via RecordChange.
spaceID := extractSpaceID(change)
err := h.store.RecordChange(ctx, appID, change.Table, change.ID, userID, spaceID, change.Op, clientID, data, fieldTimestamps, rowSchemaVersion, change.Actor)
if err != nil {
slog.Error("failed to record change", "error", err, "table", change.Table, "id", change.ID)
http.Error(w, "failed to record change: "+err.Error(), http.StatusInternalServerError)

View file

@ -0,0 +1,84 @@
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)
}
}

View file

@ -32,6 +32,13 @@ type Change struct {
Fields map[string]*FieldChange `json:"fields,omitempty"`
Data map[string]any `json:"data,omitempty"`
DeletedAt *string `json:"deletedAt,omitempty"`
// SpaceID is the Better Auth organization id the record belongs to.
// Stamped client-side by the Dexie v28 hook from the user's active
// space (or the `_personal:<userId>` sentinel during the bootstrap
// window). Stored server-side in the space_id column so future
// queries can partition by space. Pre-v28 clients omit it; the
// column is nullable. See docs/plans/spaces-foundation.md.
SpaceID string `json:"spaceId,omitempty"`
// Actor is the opaque JSON object the webapp stamps on every pending
// change (see `data/events/actor.ts`): one of `{ kind: 'user' }`,
// `{ kind: 'ai', missionId, iterationId, rationale }`, or