mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(sync): F5 — drop public userContextStore.ensureDoc()
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) <noreply@anthropic.com>
This commit is contained in:
parent
c7d80e3423
commit
d78f57c041
4 changed files with 25 additions and 18 deletions
|
|
@ -32,9 +32,7 @@
|
|||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
void userContextStore.ensureDoc();
|
||||
});
|
||||
onMount(() => {});
|
||||
|
||||
$effect(() => {
|
||||
if (!ctx) return;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@
|
|||
const VOICE_INPUT_TYPES: QuestionInputType[] = ['text', 'textarea', 'tags'];
|
||||
|
||||
onMount(() => {
|
||||
void userContextStore.ensureDoc();
|
||||
if (initialVoiceLevel) {
|
||||
voiceLevel = initialVoiceLevel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@
|
|||
let editValue = $state<string | string[]>('');
|
||||
let tagInput = $state('');
|
||||
|
||||
onMount(() => {
|
||||
void userContextStore.ensureDoc();
|
||||
});
|
||||
onMount(() => {});
|
||||
|
||||
function startEdit(field: string, current: unknown) {
|
||||
editingField = field;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
/** 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<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
async function readDecrypted(): Promise<LocalUserContext> {
|
||||
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<K extends 'about' | 'routine' | 'nutrition' | 'leisure' | 'social'>(
|
||||
section: K,
|
||||
|
|
@ -49,7 +61,7 @@ export const userContextStore = {
|
|||
? UserContextLeisure
|
||||
: UserContextSocial
|
||||
): Promise<void> {
|
||||
await ensureDoc();
|
||||
await getOrCreateLocalDoc();
|
||||
const current = await readDecrypted();
|
||||
const merged = { ...current[section], ...value };
|
||||
const diff: Partial<LocalUserContext> = {
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await ensureDoc();
|
||||
await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalUserContext> = {
|
||||
interests,
|
||||
};
|
||||
|
|
@ -112,7 +124,7 @@ export const userContextStore = {
|
|||
|
||||
/** Replace the goals array. */
|
||||
async setGoals(goals: string[]): Promise<void> {
|
||||
await ensureDoc();
|
||||
await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalUserContext> = {
|
||||
goals,
|
||||
};
|
||||
|
|
@ -122,7 +134,7 @@ export const userContextStore = {
|
|||
|
||||
/** Set freeform markdown content. */
|
||||
async setFreeform(content: string): Promise<void> {
|
||||
await ensureDoc();
|
||||
await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalUserContext> = {
|
||||
freeform: content,
|
||||
};
|
||||
|
|
@ -140,7 +152,7 @@ export const userContextStore = {
|
|||
|
||||
/** Mark a question as answered in the interview progress. */
|
||||
async markAnswered(questionId: string): Promise<void> {
|
||||
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<void> {
|
||||
await ensureDoc();
|
||||
await getOrCreateLocalDoc();
|
||||
const current = await readDecrypted();
|
||||
const interview = { ...current.interview };
|
||||
if (!interview.skippedIds.includes(questionId)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue