From c07db300b081bcd55240aadb38e549e4f3397ab9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 23:18:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20F4=20=E2=80=94=20server-side=20si?= =?UTF-8?q?ngleton=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the userContext race-on-first-mount that surfaced as a "10 fields overwritten" conflict toast pre-F2. Adds a fire-and-forget hook in the /register flow that writes the per-user `userContext` singleton straight into `mana_sync.sync_changes` with `client_id='system:bootstrap'` and `origin='system'`. Behavior: - On successful `signUpEmail`, `bootstrapUserSingletons(userId, syncSql)` inserts a `profile/userContext` row with the empty-default shape that mirrors the webapp's `emptyUserContext()` factory in `apps/mana/apps/web/src/lib/modules/profile/types.ts`. - The receiving client treats the change as origin='server-replay' on apply (per F2 conflict-gate), so no toasts on first pull. - Failure is logged but does not abort registration — the webapp's existing `ensureDoc()` fallback still works during the F4→F5 transition. Module-scoped postgres pool (max=2 connections) lazy-initialized on first signUp; reused for the lifetime of the process. Same pattern as `UserDataService.getSyncSql`. Out of scope for F4: - `kontextDoc` is per-Space (not per-user) — bootstrap there will be hooked into the Space-creation flow, not /register. The webapp's `ensureDoc()` for kontextDoc stays as-is for now. - Webapp `ensureDoc()` removal is F5. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/mana-auth/src/routes/auth.ts | 24 ++++ .../src/services/bootstrap-singletons.ts | 110 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 services/mana-auth/src/services/bootstrap-singletons.ts diff --git a/services/mana-auth/src/routes/auth.ts b/services/mana-auth/src/routes/auth.ts index 64dd93376..6f62f0dfb 100644 --- a/services/mana-auth/src/routes/auth.ts +++ b/services/mana-auth/src/routes/auth.ts @@ -6,6 +6,7 @@ */ import { Hono } from 'hono'; +import postgres from 'postgres'; import { logger } from '@mana/shared-hono'; import type { AuthUser } from '../middleware/jwt-auth'; import type { BetterAuthInstance } from '../auth/better-auth.config'; @@ -13,6 +14,16 @@ import type { SecurityEventsService, AccountLockoutService } from '../services/s import type { SignupLimitService } from '../services/signup-limit'; import type { Config } from '../config'; import { sourceAppStore, passwordResetRedirectStore } from '../auth/stores'; +import { bootstrapUserSingletons } from '../services/bootstrap-singletons'; + +/** Module-scoped postgres pool for the sync DB. Lazily created on first + * signUp; reused across requests. Caller never closes — the process + * lifetime owns it. */ +let _syncSql: ReturnType | null = null; +function getSyncSql(syncDatabaseUrl: string): ReturnType { + if (!_syncSql) _syncSql = postgres(syncDatabaseUrl, { max: 2 }); + return _syncSql; +} import { AuthErrorCode, classify, @@ -129,6 +140,19 @@ export function createAuthRoutes( name: body.name || (body.email || '').split('@')[0], }), }).catch(() => {}); + // Bootstrap per-user singletons in mana_sync (userContext today; + // kontextDoc + others can join later). Fire-and-forget — failure + // only means the webapp's `ensureDoc()` fallback path will create + // the row on the first mount, which is the F4-pre behaviour. See + // docs/plans/sync-field-meta-overhaul.md F4. + bootstrapUserSingletons(response.user.id, getSyncSql(config.syncDatabaseUrl)).catch( + (err: unknown) => { + logger.error('[auth] bootstrapUserSingletons failed', { + userId: response.user?.id, + err: err instanceof Error ? err.message : String(err), + }); + } + ); } return c.json(response); diff --git a/services/mana-auth/src/services/bootstrap-singletons.ts b/services/mana-auth/src/services/bootstrap-singletons.ts new file mode 100644 index 000000000..917e4c211 --- /dev/null +++ b/services/mana-auth/src/services/bootstrap-singletons.ts @@ -0,0 +1,110 @@ +/** + * Server-side singleton bootstrap. + * + * On first user-creation, write the per-user singleton records that the + * webapp would otherwise create on demand via `ensureDoc()`. This makes + * the bootstrap deterministic — every fresh client pulls the singleton + * from mana-sync instead of racing on a local insert. + * + * Currently bootstrapped: + * - `userContext` — the structured profile + freeform markdown blob + * keyed by `id='singleton'`. Default shape mirrors the webapp's + * `emptyUserContext()` factory in `profile/types.ts`. + * + * Not bootstrapped here: + * - `kontextDoc` — per-Space, not per-user. Created on Space creation + * by the Spaces foundation; bootstrap there if needed in F4 + * follow-up. The webapp's `ensureDoc()` for kontextDoc is still + * race-anfällig but doesn't surface the symptom F4 closes. + * + * Idempotency: `ON CONFLICT (...) DO NOTHING` on the sync_changes + * primary key would only catch re-inserts of the same row id, which + * never happens (UUIDs are fresh per call). Instead the caller MUST + * gate on user-creation success — calling this twice for the same + * user will produce two insert rows for the same singleton, and the + * field-LWW replay on the client will collapse them into one record + * with the latest field-meta winning. Harmless, but wasteful, so the + * post-signUp hook in routes/auth.ts only fires it once per real + * registration. + */ + +import type postgres from 'postgres'; + +interface Actor { + kind: 'system'; + principalId: string; + displayName: string; +} + +const BOOTSTRAP_ACTOR: Actor = { + kind: 'system', + principalId: 'system:bootstrap', + displayName: 'Bootstrap', +}; + +const BOOTSTRAP_CLIENT_ID = 'system:bootstrap'; +const BOOTSTRAP_ORIGIN = 'system'; + +/** + * Default content for a new user's `userContext` singleton. Keep in sync + * with `apps/mana/apps/web/src/lib/modules/profile/types.ts:emptyUserContext()`. + * If the shape ever drifts, the receiving client will merge whatever + * fields the server emits via field-LWW — extra fields stay at their + * default (`undefined` → no override), missing fields default to the + * client's local TypeScript shape on read. + */ +function emptyUserContextData(userId: string): Record { + return { + id: 'singleton', + about: {}, + interests: [], + routine: {}, + nutrition: {}, + leisure: {}, + goals: [], + social: {}, + freeform: '', + interview: { answeredIds: [], skippedIds: [] }, + userId, + }; +} + +/** + * Insert the per-user singletons into mana_sync.sync_changes. Called + * fire-and-forget from the post-signUp hook in routes/auth.ts; failures + * are logged but do not abort registration (the webapp's `ensureDoc()` + * is still in place as a fallback for the F4-F5 transition window). + */ +export async function bootstrapUserSingletons( + userId: string, + syncSql: ReturnType +): Promise { + if (!userId) throw new Error('bootstrapUserSingletons: empty userId'); + + const now = new Date().toISOString(); + const data = emptyUserContextData(userId); + + // Per-field at stamp for every real data field. The receiving client + // reads `field_meta` to populate `__fieldMeta[k] = { at, actor: + // changeActor, origin: 'server-replay' }` — no conflicts, no toasts. + const fieldMeta: Record = {}; + for (const key of Object.keys(data)) { + if (key === 'id') continue; + fieldMeta[key] = 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 ( + 'profile', 'userContext', 'singleton', ${userId}, NULL, 'insert', + ${syncSql.json(data as never)}, + ${syncSql.json(fieldMeta as never)}, + ${BOOTSTRAP_CLIENT_ID}, 1, + ${syncSql.json(BOOTSTRAP_ACTOR as never)}, + ${BOOTSTRAP_ORIGIN} + ) + `; +}