mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01: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 { 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);
|
||||||
|
|
|
||||||
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