feat(sync): F4 — server-side singleton bootstrap

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 23:18:54 +02:00
parent 6bb9d77be9
commit c07db300b0
2 changed files with 134 additions and 0 deletions

View file

@ -6,6 +6,7 @@
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import postgres from 'postgres';
import { logger } from '@mana/shared-hono'; import { logger } from '@mana/shared-hono';
import type { AuthUser } from '../middleware/jwt-auth'; import type { AuthUser } from '../middleware/jwt-auth';
import type { BetterAuthInstance } from '../auth/better-auth.config'; 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 { SignupLimitService } from '../services/signup-limit';
import type { Config } from '../config'; import type { Config } from '../config';
import { sourceAppStore, passwordResetRedirectStore } from '../auth/stores'; 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<typeof postgres> | null = null;
function getSyncSql(syncDatabaseUrl: string): ReturnType<typeof postgres> {
if (!_syncSql) _syncSql = postgres(syncDatabaseUrl, { max: 2 });
return _syncSql;
}
import { import {
AuthErrorCode, AuthErrorCode,
classify, classify,
@ -129,6 +140,19 @@ export function createAuthRoutes(
name: body.name || (body.email || '').split('@')[0], name: body.name || (body.email || '').split('@')[0],
}), }),
}).catch(() => {}); }).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); return c.json(response);

View file

@ -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<string, unknown> {
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<typeof postgres>
): Promise<void> {
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<string, string> = {};
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}
)
`;
}