feat(spaces): rename legacy spaceId → contextSpaceId (v31 migration)

Resolves the name collision flagged in the Spaces RFC: four tables
owned the term "spaceId" before the multi-tenancy Spaces foundation
landed in v28 (conversations, documents, spaceMembers, memoSpaces
— chat's context-folder reference, context's parent context-space,
and memoro's membership/join tables). After v28, the scope wrapper
started filtering on a field that meant something different in these
tables, which would have hidden their records from the UI.

Dexie v31 migration:
- Renames the index from spaceId → contextSpaceId on all four tables.
- upgrade() copies each existing `spaceId` value to `contextSpaceId`
  (when it's a real context-space reference and not already the v28
  `_personal:<userId>` sentinel), then resets `spaceId` to the
  personal-space sentinel so the scope wrapper picks the row up on the
  active-space boot pass.

Type changes:
- LocalConversation, Conversation:           spaceId → contextSpaceId
- LocalDocument:                             spaceId → contextSpaceId
- LocalSpaceMember, LocalMemoSpace (memoro): spaceId → contextSpaceId

Code updates:
- chat/queries.ts: toConversation + filterBySpace renamed to
  filterByContextSpace (exports updated in chat/index.ts).
- chat/stores/conversations.svelte.ts: create() param + write site.
- context/queries.ts: toDocument + useSpaceDocuments signature.
- context/collections.ts: seed data.
- context/ListView.svelte + route pages: form data.
- dashboard/widgets/ContextDocsWidget.svelte: read site.

Table names stay: `spaceMembers` and `memoSpaces` still carry their old
names because they belong to the memoro module's context-space concept
and table renames also require sync-routing updates. A dedicated
cleanup can rebrand those once memoro's data model is revisited.

0 errors across 7148 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 19:52:44 +02:00
parent a12928b7d8
commit 4ff95b2315
13 changed files with 94 additions and 23 deletions

View file

@ -56,8 +56,10 @@
<span>{typeIcons[doc.type ?? 'text'] ?? '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{doc.title}</p>
{#if getSpaceName(doc.spaceId)}
<p class="truncate text-xs text-muted-foreground">{getSpaceName(doc.spaceId)}</p>
{#if getSpaceName(doc.contextSpaceId)}
<p class="truncate text-xs text-muted-foreground">
{getSpaceName(doc.contextSpaceId)}
</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">

View file

@ -672,6 +672,72 @@ db.version(30).stores({
_serverIterationExecutions: 'iterationId, missionId, executedAt',
});
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on four
// tables that owned the term before the multi-tenancy Spaces foundation
// arrived (v28):
// - conversations (chat module's reference to a context-space folder)
// - documents (context module's parent context-space)
// - spaceMembers (memoro's members of a context-space)
// - memoSpaces (memoro's memo ↔ context-space join)
//
// The v28 upgrade did NOT overwrite pre-existing `spaceId` values, so
// records in these tables still carry context-space references under
// the old name. After this migration, `spaceId` belongs exclusively to
// the multi-tenancy primitive; the context-space reference has its own
// disambiguated field. Scope queries that previously would have been
// confused by the collision now work cleanly.
//
// The upgrade also stamps the fresh `spaceId` with the personal-space
// sentinel for these rows so they immediately participate in scope
// filtering instead of staying invisible until the next write.
//
// See docs/plans/spaces-foundation.md §"Legacy spaceId collision".
db.version(31)
.stores({
conversations: 'id, isArchived, isPinned, contextSpaceId, templateId, updatedAt',
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], updatedAt',
spaceMembers: 'id, contextSpaceId, userId',
memoSpaces: 'id, memoId, contextSpaceId',
})
.upgrade(async (tx) => {
const tables = ['conversations', 'documents', 'spaceMembers', 'memoSpaces'] as const;
for (const name of tables) {
await tx
.table(name)
.toCollection()
.modify((record: Record<string, unknown>) => {
if (record.contextSpaceId !== undefined) return;
const legacy = record.spaceId;
if (typeof legacy === 'string' && legacy && !legacy.startsWith('_personal:')) {
// Genuine context-space reference — move to the new field name.
record.contextSpaceId = legacy;
} else {
record.contextSpaceId = null;
}
const ownerId =
typeof record.userId === 'string' && record.userId ? record.userId : GUEST_USER_ID;
// Reset spaceId so scope filtering matches the user's personal
// space (post-bootstrap it's rewritten to the real personal-space id).
record.spaceId = `_personal:${ownerId}`;
});
}
});
// v32 — Broadcast module: 1:N email campaigns (newsletters).
// See docs/plans/broadcast-module.md. Three tables:
// - broadcastCampaigns: the campaigns themselves. status + scheduledAt
// indexed because the two hot queries are "show me drafts" and
// "what's scheduled in the next 24h" (server cron picks those up).
// - broadcastTemplates: reusable content templates. isBuiltIn indexed
// so the picker can split user-created vs. shipped-with-app.
// - broadcastSettings: singleton per user (sender defaults + DNS check
// cache). id is the BROADCAST_SETTINGS_ID sentinel.
db.version(32).stores({
broadcastCampaigns: 'id, status, scheduledAt, sentAt',
broadcastTemplates: 'id, isBuiltIn',
broadcastSettings: 'id',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -14,7 +14,7 @@ export {
toTemplate,
toMessage,
sortConversations,
filterBySpace,
filterByContextSpace,
filterBySearch,
splitPinned,
} from './queries';

View file

@ -27,7 +27,7 @@ export function toConversation(local: LocalConversation): Conversation {
id: local.id,
modelId: local.modelId ?? '',
templateId: local.templateId ?? undefined,
spaceId: local.spaceId ?? undefined,
contextSpaceId: local.contextSpaceId ?? undefined,
conversationMode: local.conversationMode,
documentMode: local.documentMode,
title: local.title ?? undefined,
@ -131,8 +131,11 @@ export function sortConversations(list: Conversation[]): Conversation[] {
}
/** Filter conversations by space. */
export function filterBySpace(conversations: Conversation[], spaceId: string): Conversation[] {
return conversations.filter((c) => c.spaceId === spaceId);
export function filterByContextSpace(
conversations: Conversation[],
contextSpaceId: string
): Conversation[] {
return conversations.filter((c) => c.contextSpaceId === contextSpaceId);
}
/** Filter conversations by search query on title. */

View file

@ -24,7 +24,7 @@ export const conversationsStore = {
async create(data: {
modelId?: string;
templateId?: string;
spaceId?: string;
contextSpaceId?: string;
mode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
title?: string;
@ -34,7 +34,7 @@ export const conversationsStore = {
title: data.title ?? null,
modelId: data.modelId ?? null,
templateId: data.templateId ?? null,
spaceId: data.spaceId ?? null,
contextSpaceId: data.contextSpaceId ?? null,
conversationMode: data.mode ?? 'free',
documentMode: data.documentMode ?? false,
isArchived: false,

View file

@ -8,7 +8,7 @@ export interface LocalConversation extends BaseRecord {
title?: string | null;
modelId?: string | null;
templateId?: string | null;
spaceId?: string | null;
contextSpaceId?: string | null;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
@ -38,7 +38,7 @@ export interface Conversation {
id: string;
modelId: string;
templateId?: string;
spaceId?: string;
contextSpaceId?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
title?: string;

View file

@ -15,7 +15,7 @@
const id = crypto.randomUUID();
const row: LocalDocument = {
id,
spaceId: null,
contextSpaceId: null,
title: 'Neues Dokument',
content: '# Neues Dokument\n\n',
type: 'text',

View file

@ -29,7 +29,7 @@ export const CONTEXT_GUEST_SEED = {
documents: [
{
id: 'doc-welcome',
spaceId: DEMO_SPACE_ID,
contextSpaceId: DEMO_SPACE_ID,
title: 'Willkommen bei Context',
content:
'Context ist dein KI-gestütztes Dokumenten-Management. Erstelle Texte, sammle Kontexte und nutze KI-Prompts.\n\nMelde dich an, um deine Dokumente zu synchronisieren.',
@ -40,7 +40,7 @@ export const CONTEXT_GUEST_SEED = {
},
{
id: 'doc-prompt',
spaceId: DEMO_SPACE_ID,
contextSpaceId: DEMO_SPACE_ID,
title: 'Beispiel-Prompt',
content: 'Fasse den folgenden Text in 3 Stichpunkten zusammen:\n\n{text}',
type: 'prompt' as const,

View file

@ -35,7 +35,7 @@ export function toDocument(local: LocalDocument): Document {
title: local.title,
content: local.content,
type: local.type,
space_id: local.spaceId ?? null,
space_id: local.contextSpaceId ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
@ -73,13 +73,13 @@ export function useAllDocuments() {
}, [] as Document[]);
}
/** Documents for a specific space. Auto-updates on any change. */
export function useSpaceDocuments(spaceId: string) {
/** Documents for a specific context-space. Auto-updates on any change. */
export function useSpaceDocuments(contextSpaceId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalDocument>('documents')
.where('spaceId')
.equals(spaceId)
.where('contextSpaceId')
.equals(contextSpaceId)
.toArray();
const visible = locals.filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('documents', visible);

View file

@ -39,7 +39,7 @@ export interface LocalContextSpace extends BaseRecord {
}
export interface LocalDocument extends BaseRecord {
spaceId?: string | null;
contextSpaceId?: string | null;
title: string;
content: string;
type: DocumentType;

View file

@ -62,14 +62,14 @@ export interface LocalSpace extends BaseRecord {
}
export interface LocalSpaceMember extends BaseRecord {
spaceId: string;
contextSpaceId: string;
userId: string;
role: 'owner' | 'member';
}
export interface LocalMemoSpace extends BaseRecord {
memoId: string;
spaceId: string;
contextSpaceId: string;
}
// ─── View Types ────────────────────────────────────────────

View file

@ -35,7 +35,7 @@
const id = crypto.randomUUID();
const row: LocalDocument = {
id,
spaceId: null,
contextSpaceId: null,
title: 'Neues Dokument',
content: '# Neues Dokument\n\n',
type: 'text',

View file

@ -42,7 +42,7 @@
const id = crypto.randomUUID();
const row: LocalDocument = {
id,
spaceId,
contextSpaceId: spaceId,
title: 'Neues Dokument',
content: '# Neues Dokument\n\n',
type: 'text',