feat(kontext): Phase 2d.2 — kontextDoc is per-Space, not user-singleton

Since Phase 2d.2 of the space-scoped rollout, each Space can have its
own kontextDoc. Before this commit, the module was a user-level
singleton keyed by id='singleton' — which meant Shared/Brand/Family
Spaces saw the user's Personal-Space bio as their AI planner context.

Changes:
- types.ts: relax LocalKontextDoc.id to plain string (was the literal
  'singleton'). KONTEXT_SINGLETON_ID stays as an exported const so
  legacy Personal-Space rows (stamped before the refactor) are
  documented; no longer used at write sites.
- stores/kontext.svelte.ts: ensureDoc() finds the active-Space row via
  scopedTable(), creates a fresh UUID row if absent. setContent /
  appendContent operate on the found-or-created row's id. Personal-
  Space's legacy 'singleton' row keeps rendering because the
  `_personal:<userId>` sentinel is inside getInScopeSpaceIds()'s
  returned set.
- queries.ts: useKontextDoc() mirrors the same scopedTable filter.
- ai/missions/default-resolvers.ts: kontextIndexer surfaces the active
  Space's kontextDoc (not hardcoded 'singleton'). Shared-Spaces without
  a doc yet return an empty candidate list, which is the correct
  empty-state for the mission-input picker.

Type-check clean. No schema change; relies on v28's existing spaceId
stamping + the creating-hook's ongoing stamp (kontextDoc is in the
kontext module.config).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:35:23 +02:00
parent 219ccd3f2c
commit 8a82f3c543
4 changed files with 67 additions and 25 deletions

View file

@ -9,6 +9,7 @@
import { db } from '../../database';
import { decryptRecords } from '../../crypto';
import { scopedTable } from '../../scope/scoped-db';
import { registerInputResolver } from './input-resolvers';
import { registerInputIndexer } from './input-index';
import type { InputResolver } from './input-resolvers';
@ -166,15 +167,21 @@ const notesIndexer: InputIndexer = async () => {
};
const kontextIndexer: InputIndexer = async () => {
const doc = await db.table<KontextDocLike>('kontextDoc').get('singleton');
if (!doc) return [];
// Per-Space since Phase 2d.2: the kontextDoc for the active Space is
// the only candidate we surface to the picker. Personal-Space's legacy
// singleton row is matched via the `_personal:<userId>` sentinel in
// scopedTable's getInScopeSpaceIds(); Shared/Brand/Family Spaces that
// haven't yet authored a kontextDoc simply return an empty list.
const rows = await scopedTable<KontextDocLike, string>('kontextDoc').toArray();
const match = rows[0];
if (!match) return [];
return [
{
module: 'kontext',
table: 'kontextDoc',
id: 'singleton',
id: match.id,
label: 'Kontext-Dokument',
hint: 'Dein zentrales Markdown-Dokument',
hint: 'Dein zentrales Markdown-Dokument für diesen Space',
},
];
};

View file

@ -1,14 +1,18 @@
/**
* Kontext module reactive query for the singleton document.
* Kontext module reactive query for the active-Space document.
*
* Content is encrypted at rest. Returns null until first write; the
* view calls kontextStore.ensureDoc() on mount to materialise the row.
*
* Per-Space since Phase 2d.2: each Space has its own kontextDoc;
* Personal-Space's legacy singleton row is matched by the in-scope
* set's inclusion of the `_personal:<userId>` sentinel.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { KONTEXT_SINGLETON_ID, type KontextDoc, type LocalKontextDoc } from './types';
import { scopedTable } from '$lib/data/scope/scoped-db';
import type { KontextDoc, LocalKontextDoc } from './types';
export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
return {
@ -22,9 +26,10 @@ export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
export function useKontextDoc() {
return useLiveQueryWithDefault(
async () => {
const local = await db.table<LocalKontextDoc>('kontextDoc').get(KONTEXT_SINGLETON_ID);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('kontextDoc', [local]);
const rows = await scopedTable<LocalKontextDoc, string>('kontextDoc').toArray();
const match = rows.find((r) => !r.deletedAt);
if (!match) return null;
const [decrypted] = await decryptRecords('kontextDoc', [match]);
return decrypted ? toKontextDoc(decrypted) : null;
},
null as KontextDoc | null

View file

@ -1,40 +1,59 @@
/**
* Kontext Store singleton markdown document.
* Kontext Store per-Space markdown document.
*
* `content` is encrypted at rest. The whole module is one row keyed by
* a fixed id so there's no list/detail just read/write.
* Since Phase 2d.2 the module is Space-scoped: each Space has its own
* kontextDoc. The store finds the row via `getInScopeSpaceIds()` (which
* matches the active Space plus the legacy `_personal:<userId>` sentinel
* so Personal-Space's pre-migration singleton row still renders).
*
* `content` is encrypted at rest. The Dexie creating hook stamps
* `spaceId` on new rows automatically we just pick a fresh UUID.
*/
import { kontextDocTable } from '../collections';
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { KONTEXT_SINGLETON_ID, type LocalKontextDoc } from '../types';
import { scopedTable } from '$lib/data/scope/scoped-db';
import type { LocalKontextDoc } from '../types';
async function findForActiveSpace(): Promise<LocalKontextDoc | undefined> {
const rows = await scopedTable<LocalKontextDoc, string>('kontextDoc').toArray();
return rows.find((r) => !r.deletedAt);
}
export const kontextStore = {
async ensureDoc(): Promise<void> {
const existing = await kontextDocTable.get(KONTEXT_SINGLETON_ID);
if (existing) return;
/**
* Ensure a kontextDoc exists for the active Space. No-op if one
* already exists. Returns the row so callers can read + write the
* same id.
*/
async ensureDoc(): Promise<LocalKontextDoc> {
const existing = await findForActiveSpace();
if (existing) return existing;
const newLocal: LocalKontextDoc = {
id: KONTEXT_SINGLETON_ID,
id: crypto.randomUUID(),
content: '',
};
await encryptRecord('kontextDoc', newLocal);
await kontextDocTable.add(newLocal);
// Reload — the creating-hook stamped spaceId/authorId/actor fields.
const created = await kontextDocTable.get(newLocal.id);
if (!created) throw new Error('Failed to create kontextDoc');
return created;
},
async setContent(content: string): Promise<void> {
await this.ensureDoc();
const row = await this.ensureDoc();
const diff: Partial<LocalKontextDoc> = {
content,
updatedAt: new Date().toISOString(),
};
await encryptRecord('kontextDoc', diff);
await kontextDocTable.update(KONTEXT_SINGLETON_ID, diff);
await kontextDocTable.update(row.id, diff);
},
async appendContent(chunk: string): Promise<void> {
await this.ensureDoc();
const row = await kontextDocTable.get(KONTEXT_SINGLETON_ID);
const [decrypted] = row ? await decryptRecords('kontextDoc', [row]) : [];
const row = await this.ensureDoc();
const [decrypted] = await decryptRecords('kontextDoc', [row]);
const current = decrypted?.content ?? '';
const separator = current.trim() ? '\n\n---\n\n' : '';
await this.setContent(`${current}${separator}${chunk}`);

View file

@ -1,13 +1,24 @@
/**
* Kontext module types Singleton markdown document.
* Kontext module types per-Space markdown document.
*
* Since Phase 2d.2 of the space-scoped rollout, each Space can have its
* own kontextDoc (was: user-level singleton keyed by id='singleton').
* Personal-Space's pre-migration singleton row stays usable because its
* stamped spaceId falls inside the in-scope set returned by
* getInScopeSpaceIds(); fresh rows use random UUIDs.
*/
import type { BaseRecord } from '@mana/local-store';
/**
* Legacy singleton id pre-Phase-2d.2 the whole module was one row
* keyed by this. Kept for backward-compat lookups on Personal-Space
* records that predate the refactor; new rows use crypto.randomUUID().
*/
export const KONTEXT_SINGLETON_ID = 'singleton' as const;
export interface LocalKontextDoc extends BaseRecord {
id: typeof KONTEXT_SINGLETON_ID;
id: string;
content: string;
}