mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
1cd559ca34
commit
80dbb3b3b6
4 changed files with 54 additions and 22 deletions
|
|
@ -41,21 +41,22 @@ export class ModuleNotInSpaceError extends Error {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the set of spaceId values a record must match to be considered
|
* 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
|
* "in scope" right now.
|
||||||
* sentinel window (v28 upgrade ran, bootstrap hasn't reconciled yet) we
|
*
|
||||||
* also accept the user's personal sentinel so records written between
|
* Lenient during boot: if the active space hasn't loaded yet, falls back
|
||||||
* v28 landing and the first bootstrap don't vanish from the UI.
|
* 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[] {
|
export function getInScopeSpaceIds(): string[] {
|
||||||
const active = getActiveSpaceId();
|
const active = getActiveSpaceId();
|
||||||
if (!active) throw new ScopeNotReadyError();
|
|
||||||
const userId = getCurrentUserId();
|
const userId = getCurrentUserId();
|
||||||
const ids = [active];
|
const sentinel = userId ? personalSpaceSentinel(userId) : null;
|
||||||
if (userId) {
|
if (active) {
|
||||||
const sentinel = personalSpaceSentinel(userId);
|
return sentinel && sentinel !== active ? [active, sentinel] : [active];
|
||||||
if (!ids.includes(sentinel)) ids.push(sentinel);
|
|
||||||
}
|
}
|
||||||
return ids;
|
return sentinel ? [sentinel] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,15 @@ export type Visibility = 'space' | 'private';
|
||||||
* sync engine is scope-aware. Until then this is the authoritative
|
* sync engine is scope-aware. Until then this is the authoritative
|
||||||
* check the UI uses to decide what to show.
|
* check the UI uses to decide what to show.
|
||||||
*/
|
*/
|
||||||
export function applyVisibility<T extends { visibility?: unknown; authorId?: unknown }>(
|
export function applyVisibility<T>(records: T[]): T[] {
|
||||||
records: T[]
|
// T is unconstrained so TypeScript infers it exactly as the input
|
||||||
): T[] {
|
// type; visibility/authorId are read via a duck-typed runtime check
|
||||||
|
// so any record shape works without forcing the constraint through.
|
||||||
const me = getCurrentUserId();
|
const me = getCurrentUserId();
|
||||||
return records.filter((r) => {
|
return records.filter((r) => {
|
||||||
if (r.visibility !== 'private') return true;
|
const rec = r as { visibility?: unknown; authorId?: unknown };
|
||||||
return typeof r.authorId === 'string' && r.authorId === me;
|
if (rec.visibility !== 'private') return true;
|
||||||
|
return typeof rec.authorId === 'string' && rec.authorId === me;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
|
import { scopedForModule, applyVisibility } from '$lib/data/scope';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||||
import { eventTagOps } from './stores/tags.svelte';
|
import { eventTagOps } from './stores/tags.svelte';
|
||||||
|
|
@ -41,8 +42,9 @@ export function toCalendar(local: LocalCalendar): Calendar {
|
||||||
/** All calendars, auto-updates on any change. */
|
/** All calendars, auto-updates on any change. */
|
||||||
export function useAllCalendars() {
|
export function useAllCalendars() {
|
||||||
return useLiveQueryWithDefault(async () => {
|
return useLiveQueryWithDefault(async () => {
|
||||||
const locals = await db.table<LocalCalendar>('calendars').toArray();
|
const locals = await scopedForModule<LocalCalendar, string>('calendar', 'calendars').toArray();
|
||||||
return locals.filter((c) => !c.deletedAt).map(toCalendar);
|
const visible = applyVisibility(locals).filter((c) => !c.deletedAt);
|
||||||
|
return visible.map(toCalendar);
|
||||||
}, [] as Calendar[]);
|
}, [] as Calendar[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,14 +55,19 @@ export function useAllCalendars() {
|
||||||
export function useAllCalendarItems() {
|
export function useAllCalendarItems() {
|
||||||
return useLiveQueryWithDefault(async () => {
|
return useLiveQueryWithDefault(async () => {
|
||||||
// Fetch all non-deleted timeBlocks (filter on plaintext deletedAt
|
// Fetch all non-deleted timeBlocks (filter on plaintext deletedAt
|
||||||
// before paying the per-row decrypt cost)
|
// before paying the per-row decrypt cost). Scope filter narrows to
|
||||||
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
|
// the active space + visibility filter drops records the user isn't
|
||||||
const visibleBlocks = blocks.filter((b) => !b.deletedAt);
|
// 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);
|
const decryptedBlocks = await decryptRecords('timeBlocks', visibleBlocks);
|
||||||
|
|
||||||
// Fetch all non-deleted events for joining with calendar-type blocks
|
// Fetch all non-deleted events for joining with calendar-type blocks
|
||||||
const events = await db.table<LocalEvent>('events').toArray();
|
const events = await scopedForModule<LocalEvent, string>('calendar', 'events').toArray();
|
||||||
const visibleEvents = events.filter((e) => !e.deletedAt);
|
const visibleEvents = applyVisibility(events).filter((e) => !e.deletedAt);
|
||||||
const decryptedEvents = await decryptRecords('events', visibleEvents);
|
const decryptedEvents = await decryptRecords('events', visibleEvents);
|
||||||
const eventsById = new Map<string, LocalEvent>();
|
const eventsById = new Map<string, LocalEvent>();
|
||||||
for (const e of decryptedEvents) {
|
for (const e of decryptedEvents) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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.
|
- **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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue