mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
6bb9d77be9
commit
c07db300b0
2 changed files with 134 additions and 0 deletions
|
|
@ -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<typeof postgres> | null = null;
|
||||
function getSyncSql(syncDatabaseUrl: string): ReturnType<typeof postgres> {
|
||||
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);
|
||||
|
|
|
|||
110
services/mana-auth/src/services/bootstrap-singletons.ts
Normal file
110
services/mana-auth/src/services/bootstrap-singletons.ts
Normal 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}
|
||||
)
|
||||
`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue