feat(spaces): migrate calendar module to scoped-db wrapper (pilot)

First module to consume the scope layer — proves the model end-to-end
on a real query path.

Changes in calendar/queries.ts:
- db.table('calendars')   → scopedForModule<LocalCalendar>('calendar', 'calendars')
- db.table('timeBlocks')  → scopedForModule<LocalTimeBlock>('calendar', 'timeBlocks')
- db.table('events')      → scopedForModule<LocalEvent>('calendar', 'events')
- applyVisibility() wrapper runs on each read to drop private records
  authored by other members of a shared space.

Scope wrapper tweaks:
- getInScopeSpaceIds is now lenient during boot: if no active space has
  loaded yet, falls back to the user's personal sentinel so sentinel-
  stamped records from the v28 migration still render. Returns [] only
  when fully unauthenticated, which yields an empty-match filter.
- applyVisibility is no longer generic-constrained — T is inferred
  exactly as the input type; visibility/authorId are read via runtime
  duck-typing so arbitrary record shapes pass through cleanly.

Known follow-ups:
- Root-layout bootstrap (load active space + reconcile sentinels on
  login) is intentionally not wired up yet — needs a separate pass on
  the already-crowded (app) layout to avoid collateral damage.
- Four legacy tables (conversations, documents, spaceMembers,
  memoSpaces) carry a pre-existing `spaceId` field that points to the
  older context-space concept, not our multi-tenancy space. Renaming
  those to contextSpaceId is a tracked follow-up in the RFC — calendar
  is unaffected.

Plan: docs/plans/spaces-foundation.md (updated with the legacy-spaceId
note + lenient-scope rationale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:42:10 +02:00
parent 1cd559ca34
commit 80dbb3b3b6
4 changed files with 54 additions and 22 deletions

View file

@ -41,21 +41,22 @@ export class ModuleNotInSpaceError extends Error {
/**
* Return the set of spaceId values a record must match to be considered
* "in scope" right now. Normally just the active space, but during the
* sentinel window (v28 upgrade ran, bootstrap hasn't reconciled yet) we
* also accept the user's personal sentinel so records written between
* v28 landing and the first bootstrap don't vanish from the UI.
* "in scope" right now.
*
* Lenient during boot: if the active space hasn't loaded yet, falls back
* to the user's personal sentinel so records stamped by the v28
* migration still render. Returns `[]` only when truly unauthenticated
* that yields an empty-filter (matches nothing), which is the safest
* thing a wrapper can do pre-login.
*/
export function getInScopeSpaceIds(): string[] {
const active = getActiveSpaceId();
if (!active) throw new ScopeNotReadyError();
const userId = getCurrentUserId();
const ids = [active];
if (userId) {
const sentinel = personalSpaceSentinel(userId);
if (!ids.includes(sentinel)) ids.push(sentinel);
const sentinel = userId ? personalSpaceSentinel(userId) : null;
if (active) {
return sentinel && sentinel !== active ? [active, sentinel] : [active];
}
return ids;
return sentinel ? [sentinel] : [];
}
/**

View file

@ -25,13 +25,15 @@ export type Visibility = 'space' | 'private';
* sync engine is scope-aware. Until then this is the authoritative
* check the UI uses to decide what to show.
*/
export function applyVisibility<T extends { visibility?: unknown; authorId?: unknown }>(
records: T[]
): T[] {
export function applyVisibility<T>(records: T[]): T[] {
// T is unconstrained so TypeScript infers it exactly as the input
// type; visibility/authorId are read via a duck-typed runtime check
// so any record shape works without forcing the constraint through.
const me = getCurrentUserId();
return records.filter((r) => {
if (r.visibility !== 'private') return true;
return typeof r.authorId === 'string' && r.authorId === me;
const rec = r as { visibility?: unknown; authorId?: unknown };
if (rec.visibility !== 'private') return true;
return typeof rec.authorId === 'string' && rec.authorId === me;
});
}

View file

@ -12,6 +12,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { scopedForModule, applyVisibility } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
import { eventTagOps } from './stores/tags.svelte';
@ -41,8 +42,9 @@ export function toCalendar(local: LocalCalendar): Calendar {
/** All calendars, auto-updates on any change. */
export function useAllCalendars() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCalendar>('calendars').toArray();
return locals.filter((c) => !c.deletedAt).map(toCalendar);
const locals = await scopedForModule<LocalCalendar, string>('calendar', 'calendars').toArray();
const visible = applyVisibility(locals).filter((c) => !c.deletedAt);
return visible.map(toCalendar);
}, [] as Calendar[]);
}
@ -53,14 +55,19 @@ export function useAllCalendars() {
export function useAllCalendarItems() {
return useLiveQueryWithDefault(async () => {
// Fetch all non-deleted timeBlocks (filter on plaintext deletedAt
// before paying the per-row decrypt cost)
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const visibleBlocks = blocks.filter((b) => !b.deletedAt);
// before paying the per-row decrypt cost). Scope filter narrows to
// the active space + visibility filter drops records the user isn't
// allowed to see inside a shared space.
const blocks = await scopedForModule<LocalTimeBlock, string>(
'calendar',
'timeBlocks'
).toArray();
const visibleBlocks = applyVisibility(blocks).filter((b) => !b.deletedAt);
const decryptedBlocks = await decryptRecords('timeBlocks', visibleBlocks);
// Fetch all non-deleted events for joining with calendar-type blocks
const events = await db.table<LocalEvent>('events').toArray();
const visibleEvents = events.filter((e) => !e.deletedAt);
const events = await scopedForModule<LocalEvent, string>('calendar', 'events').toArray();
const visibleEvents = applyVisibility(events).filter((e) => !e.deletedAt);
const decryptedEvents = await decryptRecords('events', visibleEvents);
const eventsById = new Map<string, LocalEvent>();
for (const e of decryptedEvents) {

View file

@ -317,6 +317,28 @@ Via `SPACE_MODULES` in Nicht-Personal-Spaces gar nicht erst erreichbar. Kein Cod
---
## Bekannte Altlast: `spaceId`-Namenskollision
Vier bestehende Dexie-Tabellen nutzen das Feld `spaceId` bereits für das
**ältere** Context-Space-Konzept (chat-/memoro-interne Kontext-Ordner,
nicht das neue Multi-Tenancy-Space):
- `conversations` (chat) — `spaceId``contextSpaces.id`
- `documents` (context) — `spaceId``contextSpaces.id`
- `spaceMembers` (memoro) — `spaceId``contextSpaces.id`
- `memoSpaces` (memoro) — `spaceId``contextSpaces.id`
Die v28-Migration hat diese Tabellen **nicht korrumpiert**, weil der
Stamping-Code nur fehlende `spaceId`-Felder setzt (`if undefined/null`).
Bestehende Records mit Context-Space-Referenzen sind unverändert.
**Follow-up**: Rename `spaceId``contextSpaceId` auf diesen vier Tabellen
+ ihren Modulen + Dexie-v29-Migration, damit das Namensfeld eindeutig der
neuen Space-Primitive gehört. Bis dahin ist der Scope-Wrapper für diese
Tabellen nicht verwendbar — entweder Kollision erst fixen oder das
Wrapper-Filter per Modul-Ausnahme deaktivieren. Calendar, Todo, Notes etc.
sind nicht betroffen.
## Offene Fragen
- **Slug-Uniqueness-Kollision**: User Till mit `@till` kollidiert mit potentiellem Brand `@till`. Lösungsraum: Slugs global unique (einfach, aber Race um beliebte Namen) vs. Slug-Präfixe (`@user/till` vs. `@org/till` — hässlich). Vorschlag: global unique, First-Come-First-Served, User-Slug bei Signup aus E-Mail-Local-Part + Suffix bei Kollision.