mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(notes): isSpaceContext flag replaces kontext module (Option B)
Retire the kontext module entirely; the per-Space standing-context
document is now a regular Note flagged with `isSpaceContext: true`.
Daily use ("URL → Notiz") moves to the notes module as a first-class
action; the same primitive is reused by the (planned) Brand/Firma-Space
onboarding wizard to seed a Space-context Note from a URL.
Why: kontext was inconsistent — its UI was a URL-crawler that wrote
to userContext.freeform (profile module), while its kontextDoc table
+ AI-Mission-Runner auto-injection was a write-only shell with no
real editor. One concept (Notes) now carries both ad-hoc noting and
Space-context, with mutex (max 1 flagged Note per Space).
Notes module:
- types: add `isSpaceContext?: boolean` to LocalNote + Note
- queries: add `useSpaceContextNote()` (the active Space's flagged note)
- store: `markAsSpaceContext(id | null)` with mutex sweep across Space
- ListView: "Aus URL importieren" inline form (URL + crawl-mode +
KI-Zusammenfassung toggle); "Als Space-Kontext markieren" /
"Space-Kontext lösen" context-menu item; ★-Badge on flagged notes
- new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url
Notes API (apps/api):
- new modules/notes/routes.ts with /import-url (ported from kontext;
same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op)
- mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier)
- delete modules/context (UI-less /ai/generate + /ai/estimate had no
consumers; /import-url moved to notes)
- packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL
AI Mission Runner:
- default-resolvers: drop kontextResolver + kontextIndexer; the
notesIndexer flags `isSpaceContext` notes with "★ " prefix and
bubbles them to the top of the picker
- writing reference-resolver: `kind: 'kontext'` now reads the flagged
Note via scope-scan instead of the kontextDoc table; tests updated
- writing ReferencePicker: useSpaceContextNote replaces useKontextDoc
- AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop
'kontextDoc' from ENCRYPTED_SERVER_TABLES set
- ai-agents ListView: drop 'kontext' from POLICY_MODULES
Profile module:
- ContextFreeform.svelte: switch import from kontext/api to notes/api
(the URL-crawl is the same primitive; it still writes to
userContext.freeform — only the import path changed)
Dexie:
- v58: notes index gains `isSpaceContext`; kontextDoc table dropped
Kontext module deletion:
- delete apps/mana/apps/web/src/lib/modules/kontext/ entirely
- delete (app)/kontext/ route
- drop registerApp + Scroll icon from app-registry/apps.ts
- drop kontext entry from help-content
- drop kontextModuleConfig from data/module-registry.ts
- drop kontextDoc from crypto registry
mana-auth:
- bootstrap-singletons: drop bootstrapSpaceSingletons function entirely
(kontextDoc was the only per-Space singleton); userContext bootstrap
unchanged
- better-auth.config: drop kontextDoc bootstrap call from personal-space
hook + organizationHooks.afterCreateOrganization
- me-bootstrap: drop per-space bootstrap loop; response shape kept
(always-empty `spaces: {}`) for backwards-compat with older clients
Note: the still-existing legacy `context` module (CMS-style docs/spaces,
unrelated to kontext) is left in place; its cleanup landed on the
articles-bulk-import branch and is out of scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
054b9e5beb
commit
8fbdc6db77
37 changed files with 496 additions and 983 deletions
|
|
@ -28,7 +28,7 @@ import { calendarRoutes } from './modules/calendar/routes';
|
|||
import { contactsRoutes } from './modules/contacts/routes';
|
||||
import { musicRoutes } from './modules/music/routes';
|
||||
import { chatRoutes } from './modules/chat/routes';
|
||||
import { contextRoutes } from './modules/context/routes';
|
||||
import { notesRoutes } from './modules/notes/routes';
|
||||
import { pictureRoutes } from './modules/picture/routes';
|
||||
import { profileRoutes } from './modules/profile/routes';
|
||||
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
||||
|
|
@ -94,10 +94,10 @@ app.use('/api/*', authMiddleware());
|
|||
// their own records.
|
||||
const RESOURCE_MODULES = [
|
||||
'chat',
|
||||
'context',
|
||||
'food',
|
||||
'guides',
|
||||
'news-research',
|
||||
'notes',
|
||||
'picture',
|
||||
'plants',
|
||||
'research',
|
||||
|
|
@ -121,7 +121,7 @@ app.route('/api/v1/calendar', calendarRoutes);
|
|||
app.route('/api/v1/contacts', contactsRoutes);
|
||||
app.route('/api/v1/music', musicRoutes);
|
||||
app.route('/api/v1/chat', chatRoutes);
|
||||
app.route('/api/v1/context', contextRoutes);
|
||||
app.route('/api/v1/notes', notesRoutes);
|
||||
app.route('/api/v1/picture', pictureRoutes);
|
||||
app.route('/api/v1/profile', profileRoutes);
|
||||
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Context module — AI text generation + token estimation
|
||||
* Ported from apps/context/apps/server
|
||||
* Notes module — server-side helpers.
|
||||
*
|
||||
* CRUD for spaces/documents handled by mana-sync.
|
||||
* Today: a single `POST /import-url` endpoint that crawls a URL via
|
||||
* mana-crawler and optionally summarises the result with mana-llm. The
|
||||
* client treats the response as the body of a new Note (title +
|
||||
* markdown content). The same endpoint is reused by the (planned)
|
||||
* Brand/Firma-Space onboarding wizard to seed the Space-context note.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -16,8 +19,6 @@ const DEFAULT_SUMMARY_MODEL = MANA_LLM.FAST_TEXT;
|
|||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// ─── URL Import (crawler → optional LLM summary → document) ──
|
||||
|
||||
const DEEP_MAX_PAGES = 20;
|
||||
const CRAWL_POLL_INTERVAL_MS = 1500;
|
||||
const CRAWL_TIMEOUT_MS = 90_000;
|
||||
|
|
@ -25,20 +26,16 @@ const CRAWL_TIMEOUT_MS = 90_000;
|
|||
/**
|
||||
* Local LLMs love to wrap Markdown in ```markdown fences or prepend
|
||||
* a "Hier ist die Zusammenfassung:" preamble. Strip those so the
|
||||
* output renders correctly when dropped into the Kontext document.
|
||||
* output renders correctly when dropped into a Note body.
|
||||
*/
|
||||
function sanitizeSummary(raw: string): string {
|
||||
let s = raw.trim();
|
||||
// Strip a leading ```markdown / ```md / ``` fence and its closing ```.
|
||||
const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||
if (fenceMatch) s = fenceMatch[1].trim();
|
||||
// Drop a single-line preamble that ends with a colon (LLM chatter).
|
||||
const lines = s.split('\n');
|
||||
if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) {
|
||||
s = lines.slice(1).join('\n').trim();
|
||||
}
|
||||
// Demote a solitary leading H1 to H2 so it doesn't clash with our
|
||||
// section header that the frontend prepends.
|
||||
s = s.replace(/^#\s+/, '## ');
|
||||
return s;
|
||||
}
|
||||
|
|
@ -73,7 +70,7 @@ routes.post('/import-url', async (c) => {
|
|||
}
|
||||
|
||||
const creditCost = summarize ? 5 : 1;
|
||||
const validation = await validateCredits(userId, 'AI_CONTEXT_IMPORT_URL', creditCost);
|
||||
const validation = await validateCredits(userId, 'NOTES_IMPORT_URL', creditCost);
|
||||
if (!validation.hasCredits) {
|
||||
return c.json(
|
||||
{
|
||||
|
|
@ -147,7 +144,7 @@ routes.post('/import-url', async (c) => {
|
|||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Du bist ein Assistent, der Web-Inhalte in strukturierte Kontext-Dokumente zusammenfasst. ' +
|
||||
'Du bist ein Assistent, der Web-Inhalte in strukturierte Notiz-Dokumente zusammenfasst. ' +
|
||||
'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' +
|
||||
'"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' +
|
||||
'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' +
|
||||
|
|
@ -175,7 +172,7 @@ routes.post('/import-url', async (c) => {
|
|||
|
||||
await consumeCredits(
|
||||
userId,
|
||||
'AI_CONTEXT_IMPORT_URL',
|
||||
'NOTES_IMPORT_URL',
|
||||
creditCost,
|
||||
`URL import (${mode}${summarize ? ' + summary' : ''})`
|
||||
);
|
||||
|
|
@ -194,74 +191,4 @@ routes.post('/import-url', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ─── AI Generation (server-only: mana-llm) ──────────────────
|
||||
|
||||
routes.post('/ai/generate', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { prompt, documents, model, maxTokens } = await c.req.json();
|
||||
|
||||
if (!prompt) return c.json({ error: 'prompt required' }, 400);
|
||||
|
||||
// Validate credits
|
||||
const validation = await validateCredits(userId, 'AI_CONTEXT_GENERATE', 5);
|
||||
if (!validation.hasCredits) {
|
||||
return c.json(
|
||||
{ error: 'Insufficient credits', required: 5, available: validation.availableCredits },
|
||||
402
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build messages with document context
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (documents?.length) {
|
||||
const contextText = documents
|
||||
.map((d: { title: string; content: string }) => `--- ${d.title} ---\n${d.content}`)
|
||||
.join('\n\n');
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `Verwende diese Dokumente als Kontext:\n\n${contextText}`,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
model: model || MANA_LLM.FAST_TEXT,
|
||||
max_tokens: maxTokens || 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return c.json({ error: 'AI generation failed' }, 502);
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content || '';
|
||||
const tokensUsed = data.usage?.total_tokens || 0;
|
||||
|
||||
// Consume credits
|
||||
await consumeCredits(userId, 'AI_CONTEXT_GENERATE', 5, `AI generation (${tokensUsed} tokens)`);
|
||||
|
||||
return c.json({ content, tokensUsed, model: model || MANA_LLM.FAST_TEXT });
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Generation failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post('/ai/estimate', async (c) => {
|
||||
const { prompt, documents } = await c.req.json();
|
||||
const charCount =
|
||||
(prompt?.length || 0) +
|
||||
(documents || []).reduce(
|
||||
(sum: number, d: { content: string }) => sum + (d.content?.length || 0),
|
||||
0
|
||||
);
|
||||
const estimatedTokens = Math.ceil(charCount / 4);
|
||||
return c.json({ estimatedTokens, estimatedCost: 5 });
|
||||
});
|
||||
|
||||
export { routes as contextRoutes };
|
||||
export { routes as notesRoutes };
|
||||
|
|
@ -68,7 +68,6 @@ import {
|
|||
Question,
|
||||
ChatCircleDots,
|
||||
SquaresFour,
|
||||
Scroll,
|
||||
Spiral,
|
||||
Crown,
|
||||
ShootingStar,
|
||||
|
|
@ -576,16 +575,6 @@ registerApp({
|
|||
paramKey: 'conversationId',
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'kontext',
|
||||
name: 'Web-Context',
|
||||
color: '#A78B6F',
|
||||
icon: Scroll,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/kontext/KontextView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'times',
|
||||
name: 'Times',
|
||||
|
|
|
|||
|
|
@ -175,18 +175,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
'Nutze Vorlagen für wiederkehrende Aufgaben',
|
||||
],
|
||||
},
|
||||
kontext: {
|
||||
description: 'Persönliches Markdown-Dokument das der AI als Hintergrundwissen mitgegeben wird.',
|
||||
features: [
|
||||
'Freitext-Markdown',
|
||||
'Wird automatisch in AI-Missionen als Kontext injiziert',
|
||||
'Pro Agent individuell konfigurierbar',
|
||||
],
|
||||
tips: [
|
||||
'Schreibe hier Dinge die die AI über dich wissen sollte: Vorlieben, Arbeitsweise, Projekte',
|
||||
'Jeder Agent kann ein eigenes Kontext-Dokument haben',
|
||||
],
|
||||
},
|
||||
context: {
|
||||
description:
|
||||
'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.',
|
||||
|
|
|
|||
|
|
@ -36,13 +36,7 @@
|
|||
* sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission
|
||||
* referencing any of these tables triggers the dialog.
|
||||
*/
|
||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
||||
'notes',
|
||||
'tasks',
|
||||
'events',
|
||||
'journalEntries',
|
||||
'kontextDoc',
|
||||
]);
|
||||
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||
|
||||
interface Props {
|
||||
/** Mission to issue the grant for. Required — the dialog reads
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
* Default input resolvers.
|
||||
*
|
||||
* Registered from `setup.ts` so the production MissionRunner can load
|
||||
* notes / kontext / goals without every module having to know about the
|
||||
* AI subsystem. Modules that need special projection logic register their
|
||||
* own resolver on init and override these defaults.
|
||||
* notes / goals / profile / todo / calendar without every module having
|
||||
* to know about the AI subsystem. Modules that need special projection
|
||||
* logic register their own resolver on init and override these defaults.
|
||||
*
|
||||
* Space-Kontext: since the kontextDoc table was retired in favour of a
|
||||
* `notes.isSpaceContext` flag, the "this is the standing context for
|
||||
* this Space" candidate is just a regular Note marked with the flag.
|
||||
* The notesResolver handles it like any other note; the notesIndexer
|
||||
* surfaces it with a star prefix so the picker bubbles it to the top.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
|
@ -20,6 +25,7 @@ interface NoteLike {
|
|||
title?: string;
|
||||
content?: string;
|
||||
deletedAt?: string;
|
||||
isSpaceContext?: boolean;
|
||||
}
|
||||
|
||||
const notesResolver: InputResolver = async (ref) => {
|
||||
|
|
@ -35,24 +41,6 @@ const notesResolver: InputResolver = async (ref) => {
|
|||
};
|
||||
};
|
||||
|
||||
interface KontextDocLike {
|
||||
id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const kontextResolver: InputResolver = async (ref) => {
|
||||
const doc = await db.table<KontextDocLike>('kontextDoc').get(ref.id);
|
||||
if (!doc) return null;
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [doc]);
|
||||
return {
|
||||
id: ref.id,
|
||||
module: ref.module,
|
||||
table: ref.table,
|
||||
title: 'Kontext',
|
||||
content: decrypted.content ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
// ── User Context (structured profile + freeform) ──────────
|
||||
|
||||
interface UserContextLike {
|
||||
|
|
@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => {
|
|||
const all = await db.table<NoteLike>('notes').toArray();
|
||||
const visible = all.filter((n) => !n.deletedAt);
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
return decrypted
|
||||
.map<InputCandidate>((n) => ({
|
||||
const candidates = decrypted.map<InputCandidate>((n) => ({
|
||||
module: 'notes',
|
||||
table: 'notes',
|
||||
id: n.id,
|
||||
label: (n.title && n.title.trim()) || '(ohne Titel)',
|
||||
hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined,
|
||||
}))
|
||||
.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
||||
};
|
||||
|
||||
const kontextIndexer: InputIndexer = async () => {
|
||||
// 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: match.id,
|
||||
label: 'Kontext-Dokument',
|
||||
hint: 'Dein zentrales Markdown-Dokument für diesen Space',
|
||||
},
|
||||
];
|
||||
label: (n.isSpaceContext ? '★ ' : '') + ((n.title && n.title.trim()) || '(ohne Titel)'),
|
||||
hint: n.isSpaceContext
|
||||
? 'Space-Kontext (auto-injected)'
|
||||
: n.content
|
||||
? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…`
|
||||
: undefined,
|
||||
}));
|
||||
// Sort: space-context-flagged notes first, then alphabetical.
|
||||
candidates.sort((a, b) => {
|
||||
const aFirst = a.label.startsWith('★ ');
|
||||
const bFirst = b.label.startsWith('★ ');
|
||||
if (aFirst !== bFirst) return aFirst ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return candidates.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
||||
};
|
||||
|
||||
const goalsIndexer: InputIndexer = async () => {
|
||||
|
|
@ -303,13 +281,11 @@ let registered = false;
|
|||
export function registerDefaultInputResolvers(): void {
|
||||
if (registered) return;
|
||||
registerInputResolver('notes', notesResolver);
|
||||
registerInputResolver('kontext', kontextResolver);
|
||||
registerInputResolver('profile', userContextResolver);
|
||||
registerInputResolver('goals', goalsResolver);
|
||||
registerInputResolver('todo', tasksResolver);
|
||||
registerInputResolver('calendar', calendarResolver);
|
||||
registerInputIndexer('notes', notesIndexer);
|
||||
registerInputIndexer('kontext', kontextIndexer);
|
||||
registerInputIndexer('profile', userContextIndexer);
|
||||
registerInputIndexer('goals', goalsIndexer);
|
||||
registerInputIndexer('todo', tasksIndexer);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
|
||||
* boot. The server-side endpoint provisions any missing per-user
|
||||
* (`userContext`) and per-Space (`kontextDoc`) singletons in
|
||||
* `mana_sync.sync_changes`. Idempotent — a second call is a no-op.
|
||||
* `userContext` singleton in `mana_sync.sync_changes`. Idempotent — a
|
||||
* second call is a no-op.
|
||||
*
|
||||
* Why call it on boot when the signup-time hooks already do this work:
|
||||
* the hooks are fire-and-forget and a transient mana_sync outage during
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
* the first write.
|
||||
*
|
||||
* Best-effort: failures are swallowed and logged. The webapp's
|
||||
* fallback paths (`getOrCreateLocalDoc()` in `userContextStore` /
|
||||
* `kontextStore`) still cover the rare race where a write happens
|
||||
* before the bootstrap row arrives.
|
||||
* fallback path (`getOrCreateLocalDoc()` in `userContextStore`) still
|
||||
* covers the rare race where a write happens before the bootstrap row
|
||||
* arrives.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
|
|
|||
|
|
@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
|
||||
moodSettings: { enabled: false, fields: [] },
|
||||
|
||||
// ─── Kontext (legacy — now web-context, URL-crawl only) ──
|
||||
kontextDoc: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── User Context (profile hub) ──────────────────────────
|
||||
// Structured profile sections + freeform markdown. Everything
|
||||
// except the fixed id and interview progress is user-typed content.
|
||||
|
|
@ -690,8 +687,9 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
'livingOracleSnapshot',
|
||||
]),
|
||||
|
||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
||||
// per agent. Content is free-form markdown.
|
||||
// Per-agent kontext documents — free-form markdown, keyed per agent.
|
||||
// Distinct from the (retired) per-Space kontextDoc; this one stays
|
||||
// because per-agent context is still injected by the persona-runner.
|
||||
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── Quiz ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1445,6 +1445,18 @@ db.version(57).stores({
|
|||
formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]',
|
||||
});
|
||||
|
||||
// v58 — Replace the per-Space `kontextDoc` singleton with a flagged
|
||||
// Note. The `notes` table gets an `isSpaceContext` index so the AI
|
||||
// Mission Runner's resolver can find the flagged row without scanning
|
||||
// every note; the kontextDoc table is dropped entirely (the kontext
|
||||
// module's UI was a write-only shell, the table was never edit-able
|
||||
// from a real surface). Mutex (max 1 flagged note per Space) is
|
||||
// enforced by `notesStore.markAsSpaceContext`, not by Dexie.
|
||||
db.version(58).stores({
|
||||
notes: 'id, isPinned, isArchived, isSpaceContext, color, title, _updatedAtIndex',
|
||||
kontextDoc: null,
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ import { mailModuleConfig } from '$lib/modules/mail/module.config';
|
|||
import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
|
||||
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
|
||||
import { moodModuleConfig } from '$lib/modules/mood/module.config';
|
||||
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
|
||||
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||
|
|
@ -158,7 +157,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
meditateModuleConfig,
|
||||
sleepModuleConfig,
|
||||
moodModuleConfig,
|
||||
kontextModuleConfig,
|
||||
quizModuleConfig,
|
||||
profileModuleConfig,
|
||||
libraryModuleConfig,
|
||||
|
|
|
|||
|
|
@ -763,8 +763,8 @@ describe('applyServerChanges (Dexie integration)', () => {
|
|||
|
||||
it('bootstrap-twin race: local SYSTEM_BOOTSTRAP row + later server insert → no conflict, LWW wins', async () => {
|
||||
// 1. Client-side fallback creates an empty row stamped origin='system'.
|
||||
// This is what `getOrCreateLocalDoc()` does in userContextStore /
|
||||
// kontextStore when a write lands before the first sync pull.
|
||||
// This is what `getOrCreateLocalDoc()` does in userContextStore
|
||||
// when a write lands before the first sync pull.
|
||||
const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP);
|
||||
await runAsAsync(bootstrapActor, async () => {
|
||||
await db.table('tasks').add({
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@
|
|||
}
|
||||
|
||||
// ── Policy editor ───────────────────────────────────────
|
||||
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'kontext', 'finance', 'drink', 'food'];
|
||||
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'finance', 'drink', 'food'];
|
||||
const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
|
||||
function policyLabel(c: PolicyDecision): string {
|
||||
return $_('ai-agents.list_view.policy_label_' + c);
|
||||
|
|
|
|||
|
|
@ -113,13 +113,7 @@
|
|||
}
|
||||
|
||||
// ── Key-Grant (server-side execution) ──────────────────
|
||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
||||
'notes',
|
||||
'tasks',
|
||||
'events',
|
||||
'journalEntries',
|
||||
'kontextDoc',
|
||||
]);
|
||||
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||
function hasEncryptedInputs(m: Mission): boolean {
|
||||
return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,360 +0,0 @@
|
|||
<!--
|
||||
Web-Context — URL crawler tool.
|
||||
Crawls web pages and appends the content to the user's profile freeform context.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { LinkSimple, X } from '@mana/shared-icons';
|
||||
import { crawlUrlViaApi, type CrawlMode } from './api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
import { userContextStore } from '$lib/modules/profile/stores/user-context.svelte';
|
||||
|
||||
let importUrl = $state('');
|
||||
let importMode = $state<CrawlMode>('single');
|
||||
let importSummarize = $state(false);
|
||||
let importing = $state(false);
|
||||
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
|
||||
let importElapsed = $state(0);
|
||||
let importError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
let importPhases = $derived.by(() => {
|
||||
const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [
|
||||
{
|
||||
key: 'crawling',
|
||||
label: importMode === 'deep' ? 'Website crawlen (bis 20 Seiten)' : 'Seite laden',
|
||||
},
|
||||
...(importSummarize ? [{ key: 'summarizing' as const, label: 'Mit KI zusammenfassen' }] : []),
|
||||
{ key: 'appending', label: 'In Profil-Kontext speichern' },
|
||||
];
|
||||
const order = steps.map((s) => s.key);
|
||||
const currentIdx = order.indexOf(importPhase as never);
|
||||
return steps.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
active: currentIdx === i,
|
||||
done: currentIdx > i,
|
||||
}));
|
||||
});
|
||||
|
||||
function reset() {
|
||||
importUrl = '';
|
||||
importMode = 'single';
|
||||
importSummarize = false;
|
||||
importPhase = 'idle';
|
||||
importElapsed = 0;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
e.preventDefault();
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'web-context-import',
|
||||
reason:
|
||||
'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.',
|
||||
});
|
||||
if (!ok) return;
|
||||
importing = true;
|
||||
importError = null;
|
||||
successMessage = null;
|
||||
importPhase = 'crawling';
|
||||
importElapsed = 0;
|
||||
const started = performance.now();
|
||||
const tick = setInterval(() => {
|
||||
importElapsed = Math.floor((performance.now() - started) / 1000);
|
||||
}, 250);
|
||||
let phaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (importSummarize) {
|
||||
const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000;
|
||||
phaseTimer = setTimeout(() => {
|
||||
if (importing) importPhase = 'summarizing';
|
||||
}, crawlBudgetMs);
|
||||
}
|
||||
try {
|
||||
const result = await crawlUrlViaApi({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
});
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
importPhase = 'appending';
|
||||
const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`;
|
||||
await userContextStore.appendFreeform(header + result.content);
|
||||
successMessage = `"${result.title}" wurde deinem Profil-Kontext hinzugefügt (${result.pageCount} Seite${result.pageCount > 1 ? 'n' : ''})`;
|
||||
reset();
|
||||
} catch (err) {
|
||||
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
|
||||
} finally {
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
clearInterval(tick);
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-view">
|
||||
<header class="header">
|
||||
<div class="header-icon">
|
||||
<LinkSimple size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="title">Web-Context</h2>
|
||||
<p class="subtitle">Crawle Webseiten und speichere den Inhalt in deinem Profil-Kontext</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="crawl-form" onsubmit={handleImport}>
|
||||
<div class="url-row">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
required
|
||||
placeholder="https://example.com/article"
|
||||
disabled={importing}
|
||||
class="url-input"
|
||||
/>
|
||||
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
|
||||
{#if importing}
|
||||
{importPhase === 'crawling'
|
||||
? 'Crawle…'
|
||||
: importPhase === 'summarizing'
|
||||
? 'Fasse zusammen…'
|
||||
: 'Speichere…'}
|
||||
{:else}
|
||||
Importieren
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="url-opts">
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
|
||||
Nur diese Seite
|
||||
</label>
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
|
||||
Ganze Website (max. 20)
|
||||
</label>
|
||||
<span class="url-sep">·</span>
|
||||
<label class:disabled={importing}>
|
||||
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
|
||||
Mit KI zusammenfassen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if importing || importPhase !== 'idle'}
|
||||
<ol class="phase-list" aria-live="polite">
|
||||
{#each importPhases as phase (phase.key)}
|
||||
<li class="phase" class:active={phase.active} class:done={phase.done}>
|
||||
<span class="phase-dot" aria-hidden="true">
|
||||
{#if phase.done}
|
||||
✓
|
||||
{:else if phase.active}
|
||||
<span class="phase-spinner"></span>
|
||||
{:else}
|
||||
·
|
||||
{/if}
|
||||
</span>
|
||||
<span class="phase-label">{phase.label}</span>
|
||||
{#if phase.active}
|
||||
<span class="phase-elapsed">{importElapsed}s</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if importError}
|
||||
<p class="error">{importError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success">
|
||||
<p>{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info">
|
||||
<p>Importierte Inhalte werden im <strong>Freitext-Tab</strong> deines Profils gespeichert.</p>
|
||||
<p>Der Crawler respektiert robots.txt und nutzt Rate-Limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.crawl-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.url-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
.url-input:focus {
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.url-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.url-opts label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-opts label.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.url-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.phase-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.phase {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.phase.active {
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
.phase.done {
|
||||
color: hsl(var(--color-success, var(--color-primary)));
|
||||
}
|
||||
.phase-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.phase-spinner {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.phase-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(142 71% 45% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(142 71% 45% / 0.08);
|
||||
color: hsl(142 71% 45%);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.success p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.info p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* Kontext module — Dexie table accessor for the singleton document.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalKontextDoc } from './types';
|
||||
|
||||
export const kontextDocTable = db.table<LocalKontextDoc>('kontextDoc');
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Kontext module — barrel exports.
|
||||
*/
|
||||
|
||||
export { kontextStore } from './stores/kontext.svelte';
|
||||
export { useKontextDoc, toKontextDoc } from './queries';
|
||||
export { kontextDocTable } from './collections';
|
||||
export { KONTEXT_SINGLETON_ID } from './types';
|
||||
export type { LocalKontextDoc, KontextDoc } from './types';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const kontextModuleConfig: ModuleConfig = {
|
||||
appId: 'kontext',
|
||||
tables: [{ name: 'kontextDoc' }],
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* Kontext module — reactive query for the active-Space document.
|
||||
*
|
||||
* Content is encrypted at rest. Returns the row as soon as it's been
|
||||
* pulled from mana-sync (every Space-creation server-bootstraps an empty
|
||||
* kontextDoc — see `bootstrap-singletons.ts`); returns null only during
|
||||
* the brief window before the first pull lands or for legacy Spaces
|
||||
* created before the bootstrap shipped.
|
||||
*
|
||||
* 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 { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedTable } from '$lib/data/scope/scoped-db';
|
||||
import type { KontextDoc, LocalKontextDoc } from './types';
|
||||
|
||||
export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
|
||||
return {
|
||||
id: local.id,
|
||||
content: local.content ?? '',
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
export function useKontextDoc() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
/**
|
||||
* Kontext Store — per-Space markdown document.
|
||||
*
|
||||
* Since Phase 2d.2 the module is Space-scoped: each Space has its own
|
||||
* kontextDoc. Since 2026-04-26 (sync-field-meta-overhaul Punkt 2) every
|
||||
* Space-creation also bootstraps an empty kontextDoc server-side via
|
||||
* `bootstrapSpaceSingletons` — fresh clients pull the row from mana-sync
|
||||
* instead of racing on a local insert. `getOrCreateLocalDoc()` below is
|
||||
* kept as a fallback for the brief window before the first pull lands
|
||||
* (and for legacy Spaces created before the bootstrap shipped).
|
||||
*
|
||||
* 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 { scopedTable } from '$lib/data/scope/scoped-db';
|
||||
import { makeSystemActor, SYSTEM_BOOTSTRAP } from '@mana/shared-ai';
|
||||
import { runAsAsync } from '$lib/data/events/actor';
|
||||
import type { LocalKontextDoc } from '../types';
|
||||
|
||||
const BOOTSTRAP_ACTOR = makeSystemActor(SYSTEM_BOOTSTRAP);
|
||||
|
||||
async function findForActiveSpace(): Promise<LocalKontextDoc | undefined> {
|
||||
const rows = await scopedTable<LocalKontextDoc, string>('kontextDoc').toArray();
|
||||
return rows.find((r) => !r.deletedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-window fallback for the narrow window between "server bootstrap
|
||||
* provisioned the row in mana_sync" and "first pull landed it in
|
||||
* IndexedDB". Without this, `setContent` / `appendContent` would hit
|
||||
* `update(missing-id, diff)` — a Dexie no-op that silently swallows the
|
||||
* write. Insert is stamped `origin: 'system'` (via SYSTEM_BOOTSTRAP)
|
||||
* so the server's bootstrap pull won't fight with it. Subsequent
|
||||
* `setContent` writes stamp `origin: 'user'` as usual.
|
||||
*/
|
||||
async function getOrCreateLocalDoc(): Promise<LocalKontextDoc> {
|
||||
const existing = await findForActiveSpace();
|
||||
if (existing) return existing;
|
||||
const newLocal: LocalKontextDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
await encryptRecord('kontextDoc', newLocal);
|
||||
await runAsAsync(BOOTSTRAP_ACTOR, async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
export const kontextStore = {
|
||||
async setContent(content: string): Promise<void> {
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalKontextDoc> = {
|
||||
content,
|
||||
};
|
||||
await encryptRecord('kontextDoc', diff);
|
||||
await kontextDocTable.update(row.id, diff);
|
||||
},
|
||||
|
||||
async appendContent(chunk: string): Promise<void> {
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [row]);
|
||||
const current = decrypted?.content ?? '';
|
||||
const separator = current.trim() ? '\n\n---\n\n' : '';
|
||||
await this.setContent(`${current}${separator}${chunk}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* 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: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface KontextDoc {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@
|
|||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
|
||||
import { PencilSimple, Trash, PushPin } from '@mana/shared-icons';
|
||||
import { PencilSimple, Trash, PushPin, LinkSimple, Scroll } from '@mana/shared-icons';
|
||||
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
|
||||
import AgentDot from '$lib/components/ai/AgentDot.svelte';
|
||||
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
|
||||
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { crawlUrl, type CrawlMode } from './api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
|
|
@ -100,6 +102,60 @@
|
|||
await notesStore.togglePin(id);
|
||||
}
|
||||
|
||||
// ─── URL Import ──────────────────────────────────────────
|
||||
let urlImportOpen = $state(false);
|
||||
let importUrl = $state('');
|
||||
let importMode = $state<CrawlMode>('single');
|
||||
let importSummarize = $state(false);
|
||||
let importing = $state(false);
|
||||
let importError = $state<string | null>(null);
|
||||
|
||||
function resetImport() {
|
||||
importUrl = '';
|
||||
importMode = 'single';
|
||||
importSummarize = false;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
e.preventDefault();
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'notes-url-import',
|
||||
reason:
|
||||
'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.',
|
||||
});
|
||||
if (!ok) return;
|
||||
importing = true;
|
||||
importError = null;
|
||||
try {
|
||||
const result = await crawlUrl({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
});
|
||||
const header = `_Quelle: ${result.sourceUrl}_\n\n`;
|
||||
const note = await notesStore.createNote({
|
||||
title: result.title,
|
||||
content: header + result.content,
|
||||
});
|
||||
urlImportOpen = false;
|
||||
resetImport();
|
||||
startEdit(note);
|
||||
} catch (err) {
|
||||
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Space-Kontext Mutex ─────────────────────────────────
|
||||
async function handleToggleSpaceContext(note: Note) {
|
||||
// Mutex enforced inside the store; flagged → unset, unflagged → set.
|
||||
await notesStore.markAsSpaceContext(note.isSpaceContext ? null : note.id);
|
||||
}
|
||||
|
||||
const ctxMenu = useItemContextMenu<Note>();
|
||||
|
||||
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
||||
|
|
@ -123,6 +179,17 @@
|
|||
if (target) notesStore.togglePin(target.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'space-context',
|
||||
label: ctxMenu.state.target.isSpaceContext
|
||||
? 'Space-Kontext lösen'
|
||||
: 'Als Space-Kontext markieren',
|
||||
icon: Scroll,
|
||||
action: () => {
|
||||
const target = ctxMenu.state.target;
|
||||
if (target) handleToggleSpaceContext(target);
|
||||
},
|
||||
},
|
||||
{ id: 'div', label: '', type: 'divider' as const },
|
||||
{
|
||||
id: 'delete',
|
||||
|
|
@ -140,6 +207,67 @@
|
|||
</script>
|
||||
|
||||
<div class="app-view">
|
||||
<!-- URL import: collapsed pill, expands to inline form on click -->
|
||||
<div class="url-import">
|
||||
{#if !urlImportOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="url-import-toggle"
|
||||
onclick={() => (urlImportOpen = true)}
|
||||
aria-label="Notiz aus URL erstellen"
|
||||
>
|
||||
<LinkSimple size={14} />
|
||||
<span>Aus URL importieren</span>
|
||||
</button>
|
||||
{:else}
|
||||
<form class="url-import-form" onsubmit={handleImport}>
|
||||
<div class="url-import-row">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
required
|
||||
placeholder="https://example.com/article"
|
||||
disabled={importing}
|
||||
class="url-import-input"
|
||||
/>
|
||||
<button type="submit" class="url-import-submit" disabled={importing || !importUrl.trim()}>
|
||||
{importing ? 'Lade…' : 'Importieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="url-import-cancel"
|
||||
onclick={() => {
|
||||
urlImportOpen = false;
|
||||
resetImport();
|
||||
}}
|
||||
disabled={importing}
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="url-import-opts">
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
|
||||
Nur diese Seite
|
||||
</label>
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
|
||||
Ganze Website (max. 20)
|
||||
</label>
|
||||
<span class="sep">·</span>
|
||||
<label class:disabled={importing}>
|
||||
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
|
||||
KI-Zusammenfassung
|
||||
</label>
|
||||
</div>
|
||||
{#if importError}
|
||||
<p class="url-import-error">{importError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if notes.length > 5}
|
||||
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
|
||||
|
|
@ -192,6 +320,14 @@
|
|||
<div class="note-top">
|
||||
<span class="note-title">{note.title || 'Unbenannt'}</span>
|
||||
<AgentDot record={note} />
|
||||
{#if note.isSpaceContext}
|
||||
<span
|
||||
class="space-context-badge"
|
||||
title="Space-Kontext — Quelle für AI-Referenzen in diesem Space"
|
||||
>
|
||||
<Scroll size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if note.isPinned}<span class="pin">📌</span>{/if}
|
||||
</div>
|
||||
{#if note.content}
|
||||
|
|
@ -250,6 +386,116 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.url-import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.url-import-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
align-self: flex-start;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.url-import-toggle:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-import-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.url-import-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.url-import-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.url-import-input:focus {
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-import-submit {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.url-import-cancel {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.url-import-opts label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-opts label.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.url-import-opts .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.url-import-error {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.space-context-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--color-primary));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/**
|
||||
* Kontext API client — talks to apps/api `POST /api/v1/context/import-url`.
|
||||
* Notes API client — talks to apps/api `POST /api/v1/notes/import-url`.
|
||||
*
|
||||
* The server route lives under /context for historical reasons (shared
|
||||
* crawler + LLM wrapper). Only the kontext singleton consumes it.
|
||||
* Crawls a URL via mana-crawler, optionally summarises the result with
|
||||
* mana-llm, and returns the markdown payload. The caller decides what
|
||||
* to do with the result — typically `notesStore.createNote({ title,
|
||||
* content })` to materialise it as a Note.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -25,10 +27,10 @@ export interface ImportResponse {
|
|||
pageCount: number;
|
||||
}
|
||||
|
||||
export async function crawlUrlViaApi(input: ImportInput): Promise<ImportResponse> {
|
||||
export async function crawlUrl(input: ImportInput): Promise<ImportResponse> {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) throw new Error('not authenticated');
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/context/import-url`, {
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/notes/import-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -35,6 +35,7 @@ export function toNote(local: LocalNote): Note {
|
|||
transcriptModel: local.transcriptModel ?? null,
|
||||
isPinned: local.isPinned,
|
||||
isArchived: local.isArchived,
|
||||
isSpaceContext: local.isSpaceContext ?? false,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
|
|
@ -63,6 +64,25 @@ export function useAllNotes() {
|
|||
}, [] as Note[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single note marked `isSpaceContext: true` in the active Space, or
|
||||
* null if no note holds that flag yet. Used by the AI Mission Runner to
|
||||
* auto-inject "what's this Space about" into every planner call. The
|
||||
* store guarantees mutex (max 1 per Space), so `find` is enough.
|
||||
*/
|
||||
export function useSpaceContextNote() {
|
||||
return useScopedLiveQuery(
|
||||
async () => {
|
||||
const raw = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||
const match = raw.find((n) => !n.deletedAt && n.isSpaceContext === true);
|
||||
if (!match) return null;
|
||||
const [decrypted] = await decryptRecords('notes', [match]);
|
||||
return decrypted ? toNote(decrypted) : null;
|
||||
},
|
||||
null as Note | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Single note by id, decrypted. Used by detail views. */
|
||||
export function useNote(id: string) {
|
||||
return useScopedLiveQuery(
|
||||
|
|
|
|||
|
|
@ -121,4 +121,41 @@ export const notesStore = {
|
|||
isArchived: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark `id` as the active Space's standing-context note for AI-Runner
|
||||
* auto-injection. Mutex: clears `isSpaceContext` on every other note
|
||||
* in the same Space first so the index can assume at most one `true`
|
||||
* row per `spaceId`. Pass `null` to unmark without setting a new one.
|
||||
*
|
||||
* The mutex sweep deliberately writes to *every* currently-flagged
|
||||
* row even though there should only ever be one — that way a sync
|
||||
* race that briefly let two rows hold the flag self-heals on the
|
||||
* next mark.
|
||||
*/
|
||||
async markAsSpaceContext(id: string | null): Promise<void> {
|
||||
const target = id ? await noteTable.get(id) : null;
|
||||
const targetSpaceId = (target as { spaceId?: string } | undefined)?.spaceId;
|
||||
|
||||
// Clear the flag on every currently-flagged note. If we have a target
|
||||
// Space, restrict to that Space; if not (id === null), the caller
|
||||
// asked for an unset of the active Space's flagged note(s) — same query
|
||||
// scoped via Dexie filter. spaceId is auto-stamped by the Dexie
|
||||
// creating-hook so it's always present at runtime even though the
|
||||
// LocalNote interface doesn't declare it.
|
||||
const flagged = await noteTable
|
||||
.filter((n) => {
|
||||
const noteSpaceId = (n as { spaceId?: string }).spaceId;
|
||||
return n.isSpaceContext === true && (!targetSpaceId || noteSpaceId === targetSpaceId);
|
||||
})
|
||||
.toArray();
|
||||
for (const n of flagged) {
|
||||
if (n.id === id) continue;
|
||||
await noteTable.update(n.id, { isSpaceContext: false });
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await noteTable.update(id, { isSpaceContext: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ export interface LocalNote extends BaseRecord {
|
|||
transcriptModel?: string | null;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
/**
|
||||
* Marks this note as the active Space's standing-context for AI Mission
|
||||
* Runner auto-injection. Mutually exclusive within a Space — the store's
|
||||
* markAsSpaceContext() unsets the flag on every other note in the same
|
||||
* Space before setting it here, so the index can assume at most one
|
||||
* `true` row per `spaceId`. Optional on the type because legacy rows
|
||||
* predate the field; absent === false.
|
||||
*/
|
||||
isSpaceContext?: boolean;
|
||||
}
|
||||
|
||||
// ─── Domain Types ─────────────────────────────────────────
|
||||
|
|
@ -28,6 +37,7 @@ export interface Note {
|
|||
transcriptModel: string | null;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
isSpaceContext: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { useUserContext } from './queries';
|
||||
import { userContextStore } from './stores/user-context.svelte';
|
||||
import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons';
|
||||
import { crawlUrlViaApi, type CrawlMode } from '$lib/modules/kontext/api';
|
||||
import { crawlUrl, type CrawlMode } from '$lib/modules/notes/api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
);
|
||||
}
|
||||
try {
|
||||
const result = await crawlUrlViaApi({
|
||||
const result = await crawlUrl({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
- note → searchable list of notes
|
||||
- library → searchable list of library entries
|
||||
- url → freeform URL input + optional context note
|
||||
- kontext → space's kontext-doc singleton (one-click add)
|
||||
- kontext → space's standing-context Note (one-click add; the Note flagged isSpaceContext)
|
||||
- goal → searchable list of goals
|
||||
- me-image → searchable list of profile reference images
|
||||
|
||||
|
|
@ -18,9 +18,8 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useAllArticles } from '$lib/modules/articles/queries';
|
||||
import { useAllNotes } from '$lib/modules/notes/queries';
|
||||
import { useAllNotes, useSpaceContextNote } from '$lib/modules/notes/queries';
|
||||
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
|
||||
import { useKontextDoc } from '$lib/modules/kontext/queries';
|
||||
import { useAllMeImages } from '$lib/modules/profile/queries';
|
||||
import { useAllGoals } from '$lib/companion/goals/queries';
|
||||
import ReferenceChip from './ReferenceChip.svelte';
|
||||
|
|
@ -36,8 +35,8 @@
|
|||
'me-image',
|
||||
];
|
||||
const MAX_REFERENCES = 6;
|
||||
/** Sentinel targetId for the kontext-singleton — the resolver doesn't
|
||||
* use it, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
||||
/** Sentinel targetId — the resolver finds the flagged note by scope-scan,
|
||||
* not by id, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
||||
const KONTEXT_SINGLETON_ID = 'kontext:singleton';
|
||||
|
||||
let {
|
||||
|
|
@ -51,7 +50,7 @@
|
|||
const articles$ = useAllArticles();
|
||||
const notes$ = useAllNotes();
|
||||
const library$ = useAllLibraryEntries();
|
||||
const kontext$ = useKontextDoc();
|
||||
const kontext$ = useSpaceContextNote();
|
||||
const meImages$ = useAllMeImages();
|
||||
const goals$ = useAllGoals();
|
||||
|
||||
|
|
@ -374,7 +373,7 @@
|
|||
<div class="search">
|
||||
{#if !kontextDoc}
|
||||
<p class="muted small">
|
||||
{$_('writing.reference_picker.kontext_empty_pre')}<a href="/kontext">/kontext</a>{$_(
|
||||
{$_('writing.reference_picker.kontext_empty_pre')}<a href="/notes">/notes</a>{$_(
|
||||
'writing.reference_picker.kontext_empty_post'
|
||||
)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,6 @@ vi.mock('$lib/modules/library/queries', () => ({
|
|||
...local,
|
||||
})),
|
||||
}));
|
||||
vi.mock('$lib/modules/kontext/queries', () => ({
|
||||
toKontextDoc: vi.fn((local) => ({ ...local })),
|
||||
}));
|
||||
|
||||
import { scopedGet, scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { db } from '$lib/data/database';
|
||||
|
|
@ -242,14 +238,25 @@ describe('resolveReference - url', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveReference - kontext (singleton)', () => {
|
||||
it('reads the singleton via scopedForModule and ignores the targetId', async () => {
|
||||
const toArrayMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'kontext-uuid', content: 'mein laufender kontext' }]);
|
||||
describe('resolveReference - kontext (Space-Kontext Note)', () => {
|
||||
it('reads the isSpaceContext-flagged Note via scopedForModule and ignores the targetId', async () => {
|
||||
const toArrayMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'note-1', title: 'Random', content: 'no flag', isSpaceContext: false },
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'Brand-Profil',
|
||||
content: 'mein laufender kontext',
|
||||
isSpaceContext: true,
|
||||
},
|
||||
]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
mockDecryptRecords.mockResolvedValue([
|
||||
{ id: 'kontext-uuid', content: 'mein laufender kontext' },
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'Brand-Profil',
|
||||
content: 'mein laufender kontext',
|
||||
isSpaceContext: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveReference({
|
||||
|
|
@ -257,15 +264,29 @@ describe('resolveReference - kontext (singleton)', () => {
|
|||
targetId: 'irrelevant',
|
||||
note: null,
|
||||
});
|
||||
expect(result?.sourceLabel).toBe('Kontext-Dokument des Spaces');
|
||||
expect(result?.sourceLabel).toBe('Space-Kontext (Notiz)');
|
||||
expect(result?.title).toBe('Brand-Profil');
|
||||
expect(result?.content).toBe('mein laufender kontext');
|
||||
});
|
||||
|
||||
it('skips deleted singleton rows', async () => {
|
||||
it('returns null when no Note is flagged as Space-Kontext', async () => {
|
||||
const toArrayMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: 'kontext-uuid', content: 'old', deletedAt: '2026-01-01T00:00:00Z' },
|
||||
.mockResolvedValue([{ id: 'note-1', title: 'Random', content: 'no flag' }]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('skips deleted Space-Kontext Notes', async () => {
|
||||
const toArrayMock = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'old context',
|
||||
content: 'old',
|
||||
isSpaceContext: true,
|
||||
deletedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ import { db } from '$lib/data/database';
|
|||
import { toArticle } from '$lib/modules/articles/queries';
|
||||
import { toNote } from '$lib/modules/notes/queries';
|
||||
import { toLibraryEntry } from '$lib/modules/library/queries';
|
||||
import { toKontextDoc } from '$lib/modules/kontext/queries';
|
||||
import type { LocalArticle } from '$lib/modules/articles/types';
|
||||
import type { LocalNote } from '$lib/modules/notes/types';
|
||||
import type { LocalLibraryEntry } from '$lib/modules/library/types';
|
||||
import type { LocalKontextDoc } from '$lib/modules/kontext/types';
|
||||
import type { LocalMeImage } from '$lib/modules/profile/types';
|
||||
import type { LocalGoal } from '$lib/companion/goals/types';
|
||||
import type { DraftReference } from '../types';
|
||||
|
|
@ -134,23 +132,23 @@ function resolveUrl(ref: DraftReference): Omit<ResolvedReference, 'kind' | 'note
|
|||
}
|
||||
|
||||
/**
|
||||
* Kontext is a per-space singleton — the picker stores a sentinel
|
||||
* targetId ('singleton'), and the resolver ignores it and picks the
|
||||
* first non-deleted row scoped to the active space. Legacy rows use
|
||||
* id='singleton' explicitly; fresh rows use a uuid but are still
|
||||
* singular per space.
|
||||
* Kontext = the active Space's standing-context Note (the one with
|
||||
* `isSpaceContext: true`). Picker stores a sentinel targetId since the
|
||||
* resolver ignores it and finds the flagged note via scope-scan.
|
||||
* Replaces the retired per-space `kontextDoc` singleton table — same
|
||||
* concept, regular Note as the storage.
|
||||
*/
|
||||
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
||||
const rows = await scopedForModule<LocalKontextDoc, string>('kontext', 'kontextDoc').toArray();
|
||||
const local = rows.find((r) => !r.deletedAt);
|
||||
const rows = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||
const local = rows.find((r) => !r.deletedAt && r.isSpaceContext === true);
|
||||
if (!local) return null;
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [local]);
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
if (!decrypted) return null;
|
||||
const doc = toKontextDoc(decrypted);
|
||||
const note = toNote(decrypted);
|
||||
return {
|
||||
sourceLabel: 'Kontext-Dokument des Spaces',
|
||||
title: 'Kontext',
|
||||
content: truncate(doc.content ?? ''),
|
||||
sourceLabel: 'Space-Kontext (Notiz)',
|
||||
title: note.title || 'Kontext',
|
||||
content: truncate(note.content ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
import KontextView from '$lib/modules/kontext/KontextView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Web-Context - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="kontext">
|
||||
<KontextView />
|
||||
</RoutePage>
|
||||
|
|
@ -50,8 +50,8 @@ export enum CreditOperationType {
|
|||
// Traces - City guide generation
|
||||
AI_GUIDE_GENERATION = 'ai_guide_generation',
|
||||
|
||||
// Context - AI text generation
|
||||
AI_CONTEXT_GENERATION = 'ai_context_generation',
|
||||
// Notes - URL crawl + optional summary into a Note
|
||||
NOTES_IMPORT_URL = 'notes_import_url',
|
||||
|
||||
// General AI features
|
||||
AI_SMART_SCHEDULING = 'ai_smart_scheduling',
|
||||
|
|
@ -104,7 +104,7 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
|||
|
||||
[CreditOperationType.AI_PLANT_ANALYSIS]: 2,
|
||||
[CreditOperationType.AI_GUIDE_GENERATION]: 5,
|
||||
[CreditOperationType.AI_CONTEXT_GENERATION]: 2,
|
||||
[CreditOperationType.NOTES_IMPORT_URL]: 1,
|
||||
|
||||
[CreditOperationType.AI_SMART_SCHEDULING]: 2,
|
||||
[CreditOperationType.AI_SUGGESTIONS]: 2,
|
||||
|
|
@ -259,12 +259,12 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
|
|||
app: 'traces',
|
||||
},
|
||||
|
||||
// Context
|
||||
[CreditOperationType.AI_CONTEXT_GENERATION]: {
|
||||
name: 'AI Text Generation',
|
||||
description: 'Generate or transform text with AI',
|
||||
// Notes
|
||||
[CreditOperationType.NOTES_IMPORT_URL]: {
|
||||
name: 'URL Import (Note)',
|
||||
description: 'Crawl a URL and create a Markdown Note (optional AI summary)',
|
||||
category: CreditCategory.AI,
|
||||
app: 'context',
|
||||
app: 'notes',
|
||||
},
|
||||
|
||||
// General AI
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ export const SYSTEM_STREAM = 'system:stream';
|
|||
export const SYSTEM_MISSION_RUNNER = 'system:mission-runner';
|
||||
/**
|
||||
* Client-side singleton bootstrap. Stamped on the rare race-window
|
||||
* `getOrCreateLocalDoc()` insert in `userContextStore` / `kontextStore`
|
||||
* — a structural twin of mana-auth's server-side bootstrap (which uses
|
||||
* the `'system:bootstrap'` principalId on the wire). Maps to
|
||||
* `getOrCreateLocalDoc()` insert in `userContextStore` — a structural
|
||||
* twin of mana-auth's server-side bootstrap (which uses the
|
||||
* `'system:bootstrap'` principalId on the wire). Maps to
|
||||
* `origin='system'` via `originFromActor`, so the conflict-gate exempts
|
||||
* it from the user-write codepath.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ const track = {
|
|||
contacts: createModuleTracker('contacts'),
|
||||
cards: createModuleTracker('cards'),
|
||||
mana: createModuleTracker('mana'),
|
||||
context: createModuleTracker('context'),
|
||||
skilltree: createModuleTracker('skilltree'),
|
||||
food: createModuleTracker('food'),
|
||||
plants: createModuleTracker('plants'),
|
||||
|
|
@ -352,18 +351,6 @@ export const ManaEvents = {
|
|||
track.mana('feature_blocked_by_auth', params),
|
||||
};
|
||||
|
||||
/**
|
||||
* Context App Events
|
||||
*/
|
||||
export const ContextEvents = {
|
||||
documentCreated: (type: string) => track.context('document_created', { type }),
|
||||
documentDeleted: () => track.context('document_deleted'),
|
||||
documentPinned: (pinned: boolean) => track.context('document_pinned', { pinned }),
|
||||
spaceCreated: () => track.context('space_created'),
|
||||
spaceDeleted: () => track.context('space_deleted'),
|
||||
aiGenerated: () => track.context('ai_generated'),
|
||||
};
|
||||
|
||||
/**
|
||||
* SkillTree App Events
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import {
|
|||
assertSpaceIsDeletable,
|
||||
createPersonalSpaceFor,
|
||||
} from '../spaces';
|
||||
import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons';
|
||||
|
||||
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
|
||||
// keep working. New code should import from './sso-origins' directly.
|
||||
|
|
@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions {
|
|||
* Create Better Auth instance
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL for the auth DB
|
||||
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The
|
||||
* personal-space + organization hooks bootstrap per-Space singletons
|
||||
* into `sync_changes` so fresh clients pull the row instead of racing
|
||||
* on a local insert. See `bootstrapSpaceSingletons`.
|
||||
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held
|
||||
* for use by the per-user `userContext` bootstrap; currently no
|
||||
* per-Space singletons are written here (the kontextDoc that used to
|
||||
* live here was retired in the Option-B cleanup).
|
||||
* @param webauthn - WebAuthn settings for the passkey plugin
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
|
|
@ -272,24 +271,6 @@ export function createBetterAuth(
|
|||
name: user.name,
|
||||
accessTier: (user as { accessTier?: string | null }).accessTier,
|
||||
});
|
||||
// Bootstrap the personal Space's kontextDoc only on a
|
||||
// real first-time creation. The `created: false` path
|
||||
// means a previous signup retry already provisioned it
|
||||
// and the doc has been bootstrapped before. Failures
|
||||
// are logged but do not abort signup — the webapp's
|
||||
// `ensureDoc()` fallback still creates the row on the
|
||||
// first write attempt.
|
||||
if (result.created) {
|
||||
bootstrapSpaceSingletons(result.organizationId, user.id, getSyncSql()).catch(
|
||||
(err: unknown) => {
|
||||
logger.error('[auth] bootstrapSpaceSingletons (personal) failed', {
|
||||
userId: user.id,
|
||||
organizationId: result.organizationId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -378,32 +359,16 @@ export function createBetterAuth(
|
|||
/**
|
||||
* Spaces — enforce that every organization carries a valid
|
||||
* `metadata.type` (the Space type), and block deletion of the
|
||||
* user's personal space. After-create bootstraps per-Space
|
||||
* singletons (currently `kontextDoc`) into mana_sync so fresh
|
||||
* clients pull the row instead of racing on a local insert.
|
||||
* Personal-space gets the same bootstrap, but from
|
||||
* `databaseHooks.user.create.after` because Better Auth's
|
||||
* `afterCreateOrganization` does not fire on the implicit
|
||||
* personal-space creation that runs inside the user-create
|
||||
* hook (createPersonalSpaceFor writes to `organizations`
|
||||
* directly via Drizzle). See docs/plans/spaces-foundation.md
|
||||
* and ../spaces/metadata.ts.
|
||||
* user's personal space. The per-Space `kontextDoc` singleton
|
||||
* that used to be bootstrapped here was retired in favour of
|
||||
* the user-driven `notes.isSpaceContext` flag (Option B
|
||||
* cleanup), so the after-create hook is currently empty —
|
||||
* kept as a hook anchor for future per-Space bootstrap needs.
|
||||
*/
|
||||
organizationHooks: {
|
||||
beforeCreateOrganization: async ({ organization }) => {
|
||||
assertValidSpaceMetadataForCreate(organization.metadata);
|
||||
},
|
||||
afterCreateOrganization: async ({ organization, user }) => {
|
||||
bootstrapSpaceSingletons(organization.id, user.id, getSyncSql()).catch(
|
||||
(err: unknown) => {
|
||||
logger.error('[auth] bootstrapSpaceSingletons (org-hook) failed', {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
beforeDeleteOrganization: async ({ organization }) => {
|
||||
assertSpaceIsDeletable(organization.metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
|
|||
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
|
||||
|
||||
// ─── Singleton Bootstrap ────────────────────────────────────
|
||||
// Idempotent reconciliation endpoint for per-user + per-Space sync
|
||||
// singletons (userContext, kontextDoc). Webapp boot calls this once;
|
||||
// signup-time hooks remain the happy path. See
|
||||
// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts.
|
||||
// Idempotent reconciliation endpoint for the per-user `userContext`
|
||||
// singleton. Webapp boot calls this once; the signup-time hook remains
|
||||
// the happy path. See docs/plans/sync-field-meta-overhaul.md and
|
||||
// routes/me-bootstrap.ts.
|
||||
app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl));
|
||||
|
||||
// ─── Settings ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,34 +2,34 @@
|
|||
* Singleton bootstrap endpoint.
|
||||
*
|
||||
* `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the
|
||||
* per-user `userContext` singleton and the per-Space `kontextDoc` for
|
||||
* every Space the caller is a member of. Called once per webapp boot
|
||||
* as a reconciliation belt-and-suspenders for the signup-time hooks
|
||||
* (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization).
|
||||
* per-user `userContext` singleton. Called once per webapp boot as a
|
||||
* reconciliation belt-and-suspenders for the signup-time hook
|
||||
* (databaseHooks.user.create.after).
|
||||
*
|
||||
* Why both: the signup hooks are zero-latency happy-path bootstraps but
|
||||
* Why both: the signup hook is a zero-latency happy-path bootstrap but
|
||||
* fire-and-forget — a transient mana_sync outage during signup leaves
|
||||
* the user with no singleton and no signal that anything is wrong. The
|
||||
* boot-time endpoint converges to the right state on every load.
|
||||
* Idempotency in the bootstrap functions makes double-invocation
|
||||
* Idempotency in the bootstrap function makes double-invocation
|
||||
* harmless.
|
||||
*
|
||||
* The endpoint is deliberately simple: no body, no parameters. The
|
||||
* caller's identity (and thus the userId + space-membership list)
|
||||
* comes from the JWT.
|
||||
* caller's identity (and thus the userId) comes from the JWT.
|
||||
*
|
||||
* Per-Space singletons used to be bootstrapped here too (kontextDoc),
|
||||
* but the kontextDoc table was retired in favour of the user-driven
|
||||
* `notes.isSpaceContext` flag — there is nothing to bootstrap per
|
||||
* Space anymore. The response shape keeps the `spaces` map for
|
||||
* backwards compatibility with older webapp builds; it is always
|
||||
* empty now.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
import { logger } from '@mana/shared-hono';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { Database } from '../db/connection';
|
||||
import { members } from '../db/schema/organizations';
|
||||
import {
|
||||
bootstrapUserSingletons,
|
||||
bootstrapSpaceSingletons,
|
||||
} from '../services/bootstrap-singletons';
|
||||
import { bootstrapUserSingletons } from '../services/bootstrap-singletons';
|
||||
|
||||
export interface BootstrapResponse {
|
||||
ok: true;
|
||||
|
|
@ -40,7 +40,7 @@ export interface BootstrapResponse {
|
|||
}
|
||||
|
||||
export function createMeBootstrapRoutes(
|
||||
db: Database,
|
||||
_db: Database,
|
||||
syncDatabaseUrl: string
|
||||
): Hono<{ Variables: { user: AuthUser } }> {
|
||||
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
|
||||
|
|
@ -71,38 +71,6 @@ export function createMeBootstrapRoutes(
|
|||
return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500);
|
||||
}
|
||||
|
||||
// Bootstrap every Space the user is a member of. The owner of a
|
||||
// Space is the canonical writer for its singletons, but RLS
|
||||
// only gates by user_id (writer); the membership-aware pull
|
||||
// delivers the row to every member regardless of which member
|
||||
// inserted it. If the owner's bootstrap failed at signup time
|
||||
// and a non-owner member calls this endpoint first, the
|
||||
// member's bootstrap stands in.
|
||||
const memberRows = await db
|
||||
.select({ organizationId: members.organizationId })
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.userId));
|
||||
|
||||
for (const row of memberRows) {
|
||||
const spaceId = row.organizationId;
|
||||
try {
|
||||
result.bootstrapped.spaces[spaceId] = await bootstrapSpaceSingletons(
|
||||
spaceId,
|
||||
user.userId,
|
||||
syncSql
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[me/bootstrap-singletons] space failed', {
|
||||
userId: user.userId,
|
||||
spaceId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
// Don't abort — surface the per-space outcome and
|
||||
// continue. The caller can retry on next boot.
|
||||
result.bootstrapped.spaces[spaceId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
/**
|
||||
* Server-side singleton bootstrap.
|
||||
*
|
||||
* On first user-creation and Space-creation, write the singleton records
|
||||
* that the webapp would otherwise create on demand via `ensureDoc()` /
|
||||
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic — every
|
||||
* fresh client pulls the singleton from mana-sync instead of racing on a
|
||||
* local insert that the next pull would clobber.
|
||||
* On first user-creation, write the singleton records that the webapp
|
||||
* would otherwise create on demand via `ensureDoc()` /
|
||||
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic —
|
||||
* every fresh client pulls the singleton from mana-sync instead of
|
||||
* racing on a local insert that the next pull would clobber.
|
||||
*
|
||||
* Currently bootstrapped:
|
||||
* - `userContext` — per-user. The structured profile + freeform markdown
|
||||
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's
|
||||
* `emptyUserContext()` factory in `profile/types.ts`.
|
||||
* - `kontextDoc` — per-Space. The freeform markdown context document
|
||||
* (encrypted at rest in normal client writes; bootstrap writes the
|
||||
* empty default in plaintext, the client's `decryptRecord` skips
|
||||
* non-envelope strings so this is safe).
|
||||
*
|
||||
* Idempotency: each function performs an existence-check on
|
||||
* (The per-Space `kontextDoc` singleton was retired — the
|
||||
* notes.isSpaceContext flag now carries the same role, and a flagged
|
||||
* Note is created on demand by the user, not bootstrapped empty.)
|
||||
*
|
||||
* Idempotency: the function performs an existence-check on
|
||||
* `sync_changes` before inserting — if a row matching the singleton's
|
||||
* scope already exists, the call is a no-op. This makes the bootstrap
|
||||
* safe to run from multiple sources without producing duplicate rows:
|
||||
* - signup-time hooks (databaseHooks.user.create.after,
|
||||
* organizationHooks.afterCreateOrganization) — fire on the happy
|
||||
* path
|
||||
* - signup-time hook (databaseHooks.user.create.after) — fires on the
|
||||
* happy path
|
||||
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires
|
||||
* on every webapp boot as a reconciliation belt-and-suspenders
|
||||
*
|
||||
|
|
@ -88,19 +87,6 @@ function emptyUserContextData(userId: string): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content for a new Space's `kontextDoc`. Just an id + empty
|
||||
* content — the user fills in the markdown later. Encryption is skipped
|
||||
* (empty string reveals nothing); the client's `decryptRecord` is
|
||||
* tolerant of plaintext values for encrypted-registry fields.
|
||||
*/
|
||||
function emptyKontextDocData(): Record<string, unknown> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the per-user singletons into mana_sync.sync_changes. Idempotent
|
||||
* — skips the insert if a row for `(userContext, 'singleton', userId)`
|
||||
|
|
@ -149,62 +135,3 @@ export async function bootstrapUserSingletons(
|
|||
`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the per-Space singletons into mana_sync.sync_changes. Idempotent
|
||||
* — skips the insert if any `kontextDoc` row already exists for the
|
||||
* given `spaceId` (regardless of writer). Called from:
|
||||
* - `databaseHooks.user.create.after` once `createPersonalSpaceFor`
|
||||
* returns `created: true` (personal-space happy path)
|
||||
* - `organizationHooks.afterCreateOrganization` (brand / club /
|
||||
* family / team / practice happy path)
|
||||
* - `POST /api/v1/me/bootstrap-singletons` for every space the
|
||||
* caller is a member of (boot-time reconciliation)
|
||||
*
|
||||
* `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope.
|
||||
* For non-personal Spaces the inviting user remains the writer — joining
|
||||
* members will receive the row via the membership-aware pull. If the
|
||||
* inviter's bootstrap somehow failed and a member triggers it later via
|
||||
* the endpoint, the member becomes the writer; the row is still
|
||||
* delivered to all members via the membership-aware pull.
|
||||
*
|
||||
* Returns true if an insert was actually written, false if the
|
||||
* idempotency check skipped it.
|
||||
*/
|
||||
export async function bootstrapSpaceSingletons(
|
||||
spaceId: string,
|
||||
ownerUserId: string,
|
||||
syncSql: ReturnType<typeof postgres>
|
||||
): Promise<boolean> {
|
||||
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
||||
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
|
||||
|
||||
const existing = await syncSql<Array<{ exists: number }>>`
|
||||
SELECT 1 AS exists
|
||||
FROM sync_changes
|
||||
WHERE table_name = 'kontextDoc'
|
||||
AND space_id = ${spaceId}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (existing.length > 0) return false;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data = emptyKontextDocData();
|
||||
const fieldMeta = buildFieldMeta(data, now);
|
||||
|
||||
await syncSql`
|
||||
INSERT INTO sync_changes (
|
||||
app_id, table_name, record_id, user_id, space_id, op, data,
|
||||
field_meta, client_id, schema_version, actor, origin
|
||||
)
|
||||
VALUES (
|
||||
'kontext', 'kontextDoc', ${data.id as string}, ${ownerUserId}, ${spaceId}, 'insert',
|
||||
${syncSql.json(data as never)},
|
||||
${syncSql.json(fieldMeta as never)},
|
||||
${BOOTSTRAP_CLIENT_ID}, 1,
|
||||
${syncSql.json(BOOTSTRAP_ACTOR as never)},
|
||||
${BOOTSTRAP_ORIGIN}
|
||||
)
|
||||
`;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue