mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23: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 saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {});
|
||||||
void userContextStore.ensureDoc();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@
|
||||||
const VOICE_INPUT_TYPES: QuestionInputType[] = ['text', 'textarea', 'tags'];
|
const VOICE_INPUT_TYPES: QuestionInputType[] = ['text', 'textarea', 'tags'];
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void userContextStore.ensureDoc();
|
|
||||||
if (initialVoiceLevel) {
|
if (initialVoiceLevel) {
|
||||||
voiceLevel = initialVoiceLevel;
|
voiceLevel = initialVoiceLevel;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@
|
||||||
let editValue = $state<string | string[]>('');
|
let editValue = $state<string | string[]>('');
|
||||||
let tagInput = $state('');
|
let tagInput = $state('');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {});
|
||||||
void userContextStore.ensureDoc();
|
|
||||||
});
|
|
||||||
|
|
||||||
function startEdit(field: string, current: unknown) {
|
function startEdit(field: string, current: unknown) {
|
||||||
editingField = field;
|
editingField = field;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@
|
||||||
*
|
*
|
||||||
* All encrypted fields are encrypted before write, decrypted on read.
|
* All encrypted fields are encrypted before write, decrypted on read.
|
||||||
* The interview progress field is NOT encrypted (structural metadata only).
|
* 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';
|
import { userContextTable } from '../collections';
|
||||||
|
|
@ -18,7 +28,11 @@ import {
|
||||||
type UserContextSocial,
|
type UserContextSocial,
|
||||||
} from '../types';
|
} 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);
|
const existing = await userContextTable.get(USER_CONTEXT_SINGLETON_ID);
|
||||||
if (existing) return;
|
if (existing) return;
|
||||||
const doc = emptyUserContext() as LocalUserContext;
|
const doc = emptyUserContext() as LocalUserContext;
|
||||||
|
|
@ -27,15 +41,13 @@ async function ensureDoc(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readDecrypted(): Promise<LocalUserContext> {
|
async function readDecrypted(): Promise<LocalUserContext> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const local = (await userContextTable.get(USER_CONTEXT_SINGLETON_ID))!;
|
const local = (await userContextTable.get(USER_CONTEXT_SINGLETON_ID))!;
|
||||||
const [decrypted] = await decryptRecords('userContext', [local]);
|
const [decrypted] = await decryptRecords('userContext', [local]);
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userContextStore = {
|
export const userContextStore = {
|
||||||
ensureDoc,
|
|
||||||
|
|
||||||
/** Replace a full section (about, routine, nutrition, leisure, social). */
|
/** Replace a full section (about, routine, nutrition, leisure, social). */
|
||||||
async updateSection<K extends 'about' | 'routine' | 'nutrition' | 'leisure' | 'social'>(
|
async updateSection<K extends 'about' | 'routine' | 'nutrition' | 'leisure' | 'social'>(
|
||||||
section: K,
|
section: K,
|
||||||
|
|
@ -49,7 +61,7 @@ export const userContextStore = {
|
||||||
? UserContextLeisure
|
? UserContextLeisure
|
||||||
: UserContextSocial
|
: UserContextSocial
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const current = await readDecrypted();
|
const current = await readDecrypted();
|
||||||
const merged = { ...current[section], ...value };
|
const merged = { ...current[section], ...value };
|
||||||
const diff: Partial<LocalUserContext> = {
|
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
|
* When `merge` is true and the value is an array, new items are added
|
||||||
* to the existing array instead of replacing it (deduped). */
|
* to the existing array instead of replacing it (deduped). */
|
||||||
async setField(path: string, value: unknown, merge = false): Promise<void> {
|
async setField(path: string, value: unknown, merge = false): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const current = await readDecrypted();
|
const current = await readDecrypted();
|
||||||
const [section, field] = path.split('.') as [keyof LocalUserContext, string];
|
const [section, field] = path.split('.') as [keyof LocalUserContext, string];
|
||||||
|
|
||||||
|
|
@ -102,7 +114,7 @@ export const userContextStore = {
|
||||||
|
|
||||||
/** Replace the interests array. */
|
/** Replace the interests array. */
|
||||||
async setInterests(interests: string[]): Promise<void> {
|
async setInterests(interests: string[]): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const diff: Partial<LocalUserContext> = {
|
const diff: Partial<LocalUserContext> = {
|
||||||
interests,
|
interests,
|
||||||
};
|
};
|
||||||
|
|
@ -112,7 +124,7 @@ export const userContextStore = {
|
||||||
|
|
||||||
/** Replace the goals array. */
|
/** Replace the goals array. */
|
||||||
async setGoals(goals: string[]): Promise<void> {
|
async setGoals(goals: string[]): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const diff: Partial<LocalUserContext> = {
|
const diff: Partial<LocalUserContext> = {
|
||||||
goals,
|
goals,
|
||||||
};
|
};
|
||||||
|
|
@ -122,7 +134,7 @@ export const userContextStore = {
|
||||||
|
|
||||||
/** Set freeform markdown content. */
|
/** Set freeform markdown content. */
|
||||||
async setFreeform(content: string): Promise<void> {
|
async setFreeform(content: string): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const diff: Partial<LocalUserContext> = {
|
const diff: Partial<LocalUserContext> = {
|
||||||
freeform: content,
|
freeform: content,
|
||||||
};
|
};
|
||||||
|
|
@ -140,7 +152,7 @@ export const userContextStore = {
|
||||||
|
|
||||||
/** Mark a question as answered in the interview progress. */
|
/** Mark a question as answered in the interview progress. */
|
||||||
async markAnswered(questionId: string): Promise<void> {
|
async markAnswered(questionId: string): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const current = await readDecrypted();
|
const current = await readDecrypted();
|
||||||
const interview = { ...current.interview };
|
const interview = { ...current.interview };
|
||||||
if (!interview.answeredIds.includes(questionId)) {
|
if (!interview.answeredIds.includes(questionId)) {
|
||||||
|
|
@ -156,7 +168,7 @@ export const userContextStore = {
|
||||||
|
|
||||||
/** Mark a question as skipped. */
|
/** Mark a question as skipped. */
|
||||||
async markSkipped(questionId: string): Promise<void> {
|
async markSkipped(questionId: string): Promise<void> {
|
||||||
await ensureDoc();
|
await getOrCreateLocalDoc();
|
||||||
const current = await readDecrypted();
|
const current = await readDecrypted();
|
||||||
const interview = { ...current.interview };
|
const interview = { ...current.interview };
|
||||||
if (!interview.skippedIds.includes(questionId)) {
|
if (!interview.skippedIds.includes(questionId)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue