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,
})),
};
}