From 80dbb3b3b6833d812d49752732645c1dba10eed9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 16:42:10 +0200 Subject: [PATCH] feat(spaces): migrate calendar module to scoped-db wrapper (pilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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('calendar', 'calendars') - db.table('timeBlocks') → scopedForModule('calendar', 'timeBlocks') - db.table('events') → scopedForModule('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) --- .../apps/web/src/lib/data/scope/scoped-db.ts | 21 +++++++++--------- .../apps/web/src/lib/data/scope/visibility.ts | 12 +++++----- .../web/src/lib/modules/calendar/queries.ts | 21 ++++++++++++------ docs/plans/spaces-foundation.md | 22 +++++++++++++++++++ 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts index 13e201b06..355642a2b 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -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] : []; } /** diff --git a/apps/mana/apps/web/src/lib/data/scope/visibility.ts b/apps/mana/apps/web/src/lib/data/scope/visibility.ts index 737ba2067..1c607ccf5 100644 --- a/apps/mana/apps/web/src/lib/data/scope/visibility.ts +++ b/apps/mana/apps/web/src/lib/data/scope/visibility.ts @@ -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( - records: T[] -): T[] { +export function applyVisibility(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; }); } diff --git a/apps/mana/apps/web/src/lib/modules/calendar/queries.ts b/apps/mana/apps/web/src/lib/modules/calendar/queries.ts index aced344e8..2223259d2 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/queries.ts @@ -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('calendars').toArray(); - return locals.filter((c) => !c.deletedAt).map(toCalendar); + const locals = await scopedForModule('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('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( + '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('events').toArray(); - const visibleEvents = events.filter((e) => !e.deletedAt); + const events = await scopedForModule('calendar', 'events').toArray(); + const visibleEvents = applyVisibility(events).filter((e) => !e.deletedAt); const decryptedEvents = await decryptRecords('events', visibleEvents); const eventsById = new Map(); for (const e of decryptedEvents) { diff --git a/docs/plans/spaces-foundation.md b/docs/plans/spaces-foundation.md index 61110aee6..ecb10e5b8 100644 --- a/docs/plans/spaces-foundation.md +++ b/docs/plans/spaces-foundation.md @@ -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.