From d78f57c041bd0ebc0d57af5a7fdf141e08ce9044 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 23:47:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20F5=20=E2=80=94=20drop=20public=20?= =?UTF-8?q?userContextStore.ensureDoc()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the on-mount `void userContextStore.ensureDoc()` race from ContextOverview / ContextInterview / ContextFreeform. After F4 the server creates the singleton at /register time; the first sync pull lands it before the UI can race. The internal logic survives as `getOrCreateLocalDoc()` — a private fallback for the brand-new client whose pull hasn't caught up yet. First user mutation (setField, setFreeform, …) inserts an empty local doc with origin='user' on the field-meta map. The F2 conflict-gate then makes sure the server's origin='system' bootstrap row never silently overwrites the user's local edits — they land in the conflict toast like a real edit-race would. `kontextStore.ensureDoc()` is intentionally kept (per-Space, not per-user; F4 didn't bootstrap it). Its removal will follow once Space-creation gains its own bootstrap hook. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/profile/ContextFreeform.svelte | 4 +-- .../modules/profile/ContextInterview.svelte | 1 - .../modules/profile/ContextOverview.svelte | 4 +-- .../profile/stores/user-context.svelte.ts | 34 +++++++++++++------ 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte index 53a16e17f..ae9322816 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte @@ -32,9 +32,7 @@ let saveTimer: ReturnType | null = null; let savedTimer: ReturnType | null = null; - onMount(() => { - void userContextStore.ensureDoc(); - }); + onMount(() => {}); $effect(() => { if (!ctx) return; diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte index 1bbe2954e..91900cf9d 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte @@ -46,7 +46,6 @@ const VOICE_INPUT_TYPES: QuestionInputType[] = ['text', 'textarea', 'tags']; onMount(() => { - void userContextStore.ensureDoc(); if (initialVoiceLevel) { voiceLevel = initialVoiceLevel; } diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte index e0aecd3d9..a0156bb63 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte @@ -24,9 +24,7 @@ let editValue = $state(''); let tagInput = $state(''); - onMount(() => { - void userContextStore.ensureDoc(); - }); + onMount(() => {}); function startEdit(field: string, current: unknown) { editingField = field; diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts index 6f1176331..6924d2322 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts @@ -3,6 +3,16 @@ * * All encrypted fields are encrypted before write, decrypted on read. * The interview progress field is NOT encrypted (structural metadata only). + * + * Singleton bootstrap (F4 of docs/plans/sync-field-meta-overhaul.md): + * the per-user `userContext` row is created server-side by mana-auth at + * `/register` time. The first sync pull lands the row before the UI ever + * tries to read it. The internal `getOrCreateLocalDoc()` helper below is + * a *fallback* — it inserts an empty doc on a brand-new client whose + * pull hasn't caught up yet. Any user edits made in that window stamp + * `origin: 'user'` via the Dexie hook, and the F2 conflict-gate makes + * sure the server's `origin: 'system'` bootstrap row never overwrites + * them silently. */ import { userContextTable } from '../collections'; @@ -18,7 +28,11 @@ import { type UserContextSocial, } from '../types'; -async function ensureDoc(): Promise { +/** Internal fallback: write a fresh empty doc if neither the server + * bootstrap (F4) nor any prior session has populated the singleton + * yet. Mutating store methods call this first so a brand-new client + * that hasn't completed its first pull can still accept edits. */ +async function getOrCreateLocalDoc(): Promise { const existing = await userContextTable.get(USER_CONTEXT_SINGLETON_ID); if (existing) return; const doc = emptyUserContext() as LocalUserContext; @@ -27,15 +41,13 @@ async function ensureDoc(): Promise { } async function readDecrypted(): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const local = (await userContextTable.get(USER_CONTEXT_SINGLETON_ID))!; const [decrypted] = await decryptRecords('userContext', [local]); return decrypted; } export const userContextStore = { - ensureDoc, - /** Replace a full section (about, routine, nutrition, leisure, social). */ async updateSection( section: K, @@ -49,7 +61,7 @@ export const userContextStore = { ? UserContextLeisure : UserContextSocial ): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const current = await readDecrypted(); const merged = { ...current[section], ...value }; const diff: Partial = { @@ -63,7 +75,7 @@ export const userContextStore = { * When `merge` is true and the value is an array, new items are added * to the existing array instead of replacing it (deduped). */ async setField(path: string, value: unknown, merge = false): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const current = await readDecrypted(); const [section, field] = path.split('.') as [keyof LocalUserContext, string]; @@ -102,7 +114,7 @@ export const userContextStore = { /** Replace the interests array. */ async setInterests(interests: string[]): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const diff: Partial = { interests, }; @@ -112,7 +124,7 @@ export const userContextStore = { /** Replace the goals array. */ async setGoals(goals: string[]): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const diff: Partial = { goals, }; @@ -122,7 +134,7 @@ export const userContextStore = { /** Set freeform markdown content. */ async setFreeform(content: string): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const diff: Partial = { freeform: content, }; @@ -140,7 +152,7 @@ export const userContextStore = { /** Mark a question as answered in the interview progress. */ async markAnswered(questionId: string): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const current = await readDecrypted(); const interview = { ...current.interview }; if (!interview.answeredIds.includes(questionId)) { @@ -156,7 +168,7 @@ export const userContextStore = { /** Mark a question as skipped. */ async markSkipped(questionId: string): Promise { - await ensureDoc(); + await getOrCreateLocalDoc(); const current = await readDecrypted(); const interview = { ...current.interview }; if (!interview.skippedIds.includes(questionId)) {