mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(auth): explicit bootstrap-singletons endpoint + idempotent functions (F4 robust)
The F4 server-side singleton bootstrap was fire-and-forget at signup
time — a transient mana_sync outage during registration would leave the
user with no singleton and only the in-store `getOrCreateLocalDoc()`
fallback to race on the first write. The signup-hook is still the
happy-path zero-latency bootstrap; this commit adds a deliberate
reconciliation path that converges on every boot.
- Idempotent `bootstrapUserSingletons` / `bootstrapSpaceSingletons`:
both functions now existence-check sync_changes before INSERT and
return boolean (true=inserted, false=skipped).
- New endpoint `POST /api/v1/me/bootstrap-singletons` — JWT-gated under
the existing `/api/v1/me/*` prefix. Provisions the caller's
userContext and the kontextDoc for every Space they're a member of.
Returns `{ ok, bootstrapped: { userContext, spaces: { id: bool } } }`.
- Webapp `(app)/+layout.svelte` calls the endpoint once per
authenticated boot, after `restoreClientIdFromDexie()` and before
`createUnifiedSync.startAll()`. Best-effort; failures swallow into a
console warning and the in-store fallback still covers the rare
race window.
Plan: docs/plans/sync-field-meta-overhaul.md (F4-robust row).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98d334045a
commit
099cac4a01
7 changed files with 261 additions and 24 deletions
|
|
@ -206,6 +206,7 @@ Sieben Phasen, die vier strukturelle Bugs in der Conflict-Detection abgeräumt h
|
||||||
- **F6** (`a031493fe`) Stable `client_id` in Dexie-Tabelle `_clientIdentity`. `restoreClientIdFromDexie()` läuft im (app)-Layout vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage. Dexie ist canonical, localStorage ist fast-read-cache. Survives clear-site-data und incognito flush.
|
- **F6** (`a031493fe`) Stable `client_id` in Dexie-Tabelle `_clientIdentity`. `restoreClientIdFromDexie()` läuft im (app)-Layout vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage. Dexie ist canonical, localStorage ist fast-read-cache. Survives clear-site-data und incognito flush.
|
||||||
- **F7** (`2a8e8ff98`) `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht — pre-live, keine Live-Daten brauchen sie. Orphan-localStorage-Flags-Sweep im Boot (`migrations-cleanup.ts`, `119cd2cf8`) räumt die zugehörigen Flags auf.
|
- **F7** (`2a8e8ff98`) `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht — pre-live, keine Live-Daten brauchen sie. Orphan-localStorage-Flags-Sweep im Boot (`migrations-cleanup.ts`, `119cd2cf8`) räumt die zugehörigen Flags auf.
|
||||||
- **F3-fu (v55 cleanup)** (_pending_) Dexie v55 row-rewrite: löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3+F5 liest niemand mehr `row.updatedAt`, also pure waste. Idempotent — rows ohne das Feld sind ein no-op.
|
- **F3-fu (v55 cleanup)** (_pending_) Dexie v55 row-rewrite: löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3+F5 liest niemand mehr `row.updatedAt`, also pure waste. Idempotent — rows ohne das Feld sind ein no-op.
|
||||||
|
- **F4-robust (Endpoint)** (_pending_) Bootstrap-Funktionen idempotent (existence-check) + expliziter Endpoint `POST /api/v1/me/bootstrap-singletons`. Webapp callt ihn auf Boot vor `createUnifiedSync`. Signup-Hooks bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders, sodass eine transient mana_sync-Outage beim Signup nicht den User mit `getOrCreateLocalDoc()`-Race auf erstem Write strandet.
|
||||||
|
|
||||||
Die vier Bug-Wurzeln (siehe ursprüngliche Diagnose 2026-04-26):
|
Die vier Bug-Wurzeln (siehe ursprüngliche Diagnose 2026-04-26):
|
||||||
|
|
||||||
|
|
|
||||||
72
apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts
Normal file
72
apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Boot-time singleton bootstrap reconciliation.
|
||||||
|
*
|
||||||
|
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
|
||||||
|
* boot. The server-side endpoint provisions any missing per-user
|
||||||
|
* (`userContext`) and per-Space (`kontextDoc`) singletons in
|
||||||
|
* `mana_sync.sync_changes`. Idempotent — a second call is a no-op.
|
||||||
|
*
|
||||||
|
* Why call it on boot when the signup-time hooks already do this work:
|
||||||
|
* the hooks are fire-and-forget and a transient mana_sync outage during
|
||||||
|
* registration can leave the user with no singleton row. The boot-time
|
||||||
|
* endpoint converges to the right state on every load, so a one-time
|
||||||
|
* outage doesn't strand the user with `getOrCreateLocalDoc()` racing on
|
||||||
|
* the first write.
|
||||||
|
*
|
||||||
|
* Best-effort: failures are swallowed and logged. The webapp's
|
||||||
|
* fallback paths (`getOrCreateLocalDoc()` in `userContextStore` /
|
||||||
|
* `kontextStore`) still cover the rare race where a write happens
|
||||||
|
* before the bootstrap row arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
function getAuthUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string })
|
||||||
|
.__PUBLIC_MANA_AUTH_URL__;
|
||||||
|
if (injected) return injected;
|
||||||
|
}
|
||||||
|
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapResponse {
|
||||||
|
ok: true;
|
||||||
|
bootstrapped: {
|
||||||
|
userContext: boolean;
|
||||||
|
spaces: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapSingletons(): Promise<void> {
|
||||||
|
if (!browser) return;
|
||||||
|
const token = await authStore.getValidToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getAuthUrl()}/api/v1/me/bootstrap-singletons`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[bootstrap-singletons] endpoint returned', res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as BootstrapResponse;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const newUser = body.bootstrapped.userContext;
|
||||||
|
const newSpaces = Object.values(body.bootstrapped.spaces).filter(Boolean).length;
|
||||||
|
if (newUser || newSpaces > 0) {
|
||||||
|
console.info(
|
||||||
|
`[bootstrap-singletons] reconciled — userContext=${newUser ? 'inserted' : 'present'}, ` +
|
||||||
|
`spaces inserted=${newSpaces}/${Object.keys(body.bootstrapped.spaces).length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[bootstrap-singletons] call failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,7 @@
|
||||||
} from '$lib/modules/memoro/llm-watcher.svelte';
|
} from '$lib/modules/memoro/llm-watcher.svelte';
|
||||||
import { createUnifiedSync, restoreClientIdFromDexie } from '$lib/data/sync';
|
import { createUnifiedSync, restoreClientIdFromDexie } from '$lib/data/sync';
|
||||||
import { cleanupOrphanMigrationFlags } from '$lib/data/migrations-cleanup';
|
import { cleanupOrphanMigrationFlags } from '$lib/data/migrations-cleanup';
|
||||||
|
import { bootstrapSingletons } from '$lib/data/bootstrap-singletons';
|
||||||
import { syncBilling } from '$lib/stores/sync-billing.svelte';
|
import { syncBilling } from '$lib/stores/sync-billing.svelte';
|
||||||
import { networkStore } from '$lib/stores/network.svelte';
|
import { networkStore } from '$lib/stores/network.svelte';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
|
|
@ -636,6 +637,12 @@
|
||||||
// Sweep stale localStorage flags from migration helpers that
|
// Sweep stale localStorage flags from migration helpers that
|
||||||
// have since been deleted (F7 + future cleanups).
|
// have since been deleted (F7 + future cleanups).
|
||||||
cleanupOrphanMigrationFlags();
|
cleanupOrphanMigrationFlags();
|
||||||
|
// Reconcile per-user + per-Space singletons via mana-auth's
|
||||||
|
// idempotent bootstrap endpoint before the sync engine starts
|
||||||
|
// pulling. Best-effort — failures fall back to the in-store
|
||||||
|
// `getOrCreateLocalDoc()` path that handles the rare race
|
||||||
|
// where a write happens before the bootstrap row arrives.
|
||||||
|
void bootstrapSingletons();
|
||||||
const getToken = () => authStore.getValidToken();
|
const getToken = () => authStore.getValidToken();
|
||||||
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
|
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
|
||||||
// Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
|
// Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@ _Wird befüllt während der Ausführung._
|
||||||
| F6 | `a031493fe` | Stable `client_id` in Dexie. Neue Tabelle `_clientIdentity` (single row keyed by `id='self'`). `restoreClientIdFromDexie()` läuft einmal beim Boot in `+layout.svelte` vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage: Dexie ist canonical, localStorage ist fast-read cache. Ein localStorage-Wipe wird beim nächsten Boot aus Dexie restored. Dexie v54 mit `_clientIdentity: 'id'`. Survives clear-site-data, incognito flush. |
|
| F6 | `a031493fe` | Stable `client_id` in Dexie. Neue Tabelle `_clientIdentity` (single row keyed by `id='self'`). `restoreClientIdFromDexie()` läuft einmal beim Boot in `+layout.svelte` vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage: Dexie ist canonical, localStorage ist fast-read cache. Ein localStorage-Wipe wird beim nächsten Boot aus Dexie restored. Dexie v54 mit `_clientIdentity: 'id'`. Survives clear-site-data, incognito flush. |
|
||||||
| F7 | `2a8e8ff98` | `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht. Beide existierten nur um die Symptome eines fixed-in-M2.5 Bugs zu cleanen, der pre-live keine echten Daten produziert hat. Mit F2's `origin='migration'` wrapper + F3's drop von synced `updatedAt` würden ihre writes auch nicht mehr als Conflicts auftauchen — sie waren strukturell überflüssig. Caller in MeImagesView + wardrobe/ListView entfernt; leere `migration/` Directory gelöscht. |
|
| F7 | `2a8e8ff98` | `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht. Beide existierten nur um die Symptome eines fixed-in-M2.5 Bugs zu cleanen, der pre-live keine echten Daten produziert hat. Mit F2's `origin='migration'` wrapper + F3's drop von synced `updatedAt` würden ihre writes auch nicht mehr als Conflicts auftauchen — sie waren strukturell überflüssig. Caller in MeImagesView + wardrobe/ListView entfernt; leere `migration/` Directory gelöscht. |
|
||||||
| F4-fu (kontextDoc) | `3df739190` | F4-Symmetrie: `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in `bootstrap-singletons.ts`, schreibt einen leeren `kontext/kontextDoc` row pro Space-Erstellung in `mana_sync.sync_changes` mit `client_id='system:bootstrap'`, `origin='system'`. Zwei Aufruf-Sites: `databaseHooks.user.create.after` (Personal-Space; nur wenn `createPersonalSpaceFor` `created: true` zurückgibt) + `organizationHooks.afterCreateOrganization` (alle non-personal Spaces). `createBetterAuth` kriegt `syncDatabaseUrl` als zweites Argument; lazy module-scoped postgres-pool. Webapp `kontextStore.ensureDoc()` zu privat `getOrCreateLocalDoc()` umbenannt — Public-API ist nur noch `setContent` + `appendContent`. Kontext content bleibt encrypted at rest auf dem Client (`kontextDoc.content`); der Server-Bootstrap schreibt `''` plaintext, was im Client-`decryptRecord` toleriert wird (non-envelope strings werden durchgereicht). |
|
| F4-fu (kontextDoc) | `3df739190` | F4-Symmetrie: `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in `bootstrap-singletons.ts`, schreibt einen leeren `kontext/kontextDoc` row pro Space-Erstellung in `mana_sync.sync_changes` mit `client_id='system:bootstrap'`, `origin='system'`. Zwei Aufruf-Sites: `databaseHooks.user.create.after` (Personal-Space; nur wenn `createPersonalSpaceFor` `created: true` zurückgibt) + `organizationHooks.afterCreateOrganization` (alle non-personal Spaces). `createBetterAuth` kriegt `syncDatabaseUrl` als zweites Argument; lazy module-scoped postgres-pool. Webapp `kontextStore.ensureDoc()` zu privat `getOrCreateLocalDoc()` umbenannt — Public-API ist nur noch `setContent` + `appendContent`. Kontext content bleibt encrypted at rest auf dem Client (`kontextDoc.content`); der Server-Bootstrap schreibt `''` plaintext, was im Client-`decryptRecord` toleriert wird (non-envelope strings werden durchgereicht). |
|
||||||
| F3-fu (v55 cleanup) | _pending_ | Dexie v55 löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3 liest niemand mehr `row.updatedAt` — `deriveUpdatedAt(local)` aus `__fieldMeta` ist die SSOT. Idempotent (delete missing field = no-op), best-effort (try/catch pro Tabelle für unbekannte/missing stores). |
|
| F3-fu (v55 cleanup) | `53fecbf4a` | Dexie v55 löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3 liest niemand mehr `row.updatedAt` — `deriveUpdatedAt(local)` aus `__fieldMeta` ist die SSOT. Idempotent (delete missing field = no-op), best-effort (try/catch pro Tabelle für unbekannte/missing stores). |
|
||||||
|
| F4-robust (Endpoint) | _pending_ | F4-Bootstrap robuster gemacht via expliziten Endpoint `POST /api/v1/me/bootstrap-singletons` in mana-auth. Beide Bootstrap-Funktionen (`bootstrapUserSingletons`, `bootstrapSpaceSingletons`) sind jetzt idempotent (existence-check vor INSERT) und geben `boolean` zurück. Endpoint ruft beide auf — userContext für den Caller, kontextDoc für jeden Space, in dem der Caller Member ist. Webapp `(app)/+layout.svelte` callt den Endpoint einmal pro Boot vor `createUnifiedSync`, fire-and-forget am Client. Signup-Hooks (`databaseHooks.user.create.after`, `organizationHooks.afterCreateOrganization`) bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders. |
|
||||||
|
|
||||||
## F1 — Implementation Notes
|
## F1 — Implementation Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { createPasskeyRoutes } from './routes/passkeys';
|
||||||
import { createGuildRoutes } from './routes/guilds';
|
import { createGuildRoutes } from './routes/guilds';
|
||||||
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
|
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
|
||||||
import { createMeRoutes } from './routes/me';
|
import { createMeRoutes } from './routes/me';
|
||||||
|
import { createMeBootstrapRoutes } from './routes/me-bootstrap';
|
||||||
import { createOnboardingRoutes } from './routes/onboarding';
|
import { createOnboardingRoutes } from './routes/onboarding';
|
||||||
import { createEncryptionVaultRoutes } from './routes/encryption-vault';
|
import { createEncryptionVaultRoutes } from './routes/encryption-vault';
|
||||||
import { createAiMissionGrantRoutes } from './routes/ai-mission-grant';
|
import { createAiMissionGrantRoutes } from './routes/ai-mission-grant';
|
||||||
|
|
@ -138,6 +139,13 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
|
||||||
// See docs/plans/onboarding-flow.md.
|
// See docs/plans/onboarding-flow.md.
|
||||||
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
|
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
|
||||||
|
|
||||||
|
// ─── Singleton Bootstrap ────────────────────────────────────
|
||||||
|
// Idempotent reconciliation endpoint for per-user + per-Space sync
|
||||||
|
// singletons (userContext, kontextDoc). Webapp boot calls this once;
|
||||||
|
// signup-time hooks remain the happy path. See
|
||||||
|
// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts.
|
||||||
|
app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl));
|
||||||
|
|
||||||
// ─── Settings ──────────────────────────────────────────────
|
// ─── Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/api/v1/settings/*', jwtAuth(config.baseUrl));
|
app.use('/api/v1/settings/*', jwtAuth(config.baseUrl));
|
||||||
|
|
|
||||||
108
services/mana-auth/src/routes/me-bootstrap.ts
Normal file
108
services/mana-auth/src/routes/me-bootstrap.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Singleton bootstrap endpoint.
|
||||||
|
*
|
||||||
|
* `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the
|
||||||
|
* per-user `userContext` singleton and the per-Space `kontextDoc` for
|
||||||
|
* every Space the caller is a member of. Called once per webapp boot
|
||||||
|
* as a reconciliation belt-and-suspenders for the signup-time hooks
|
||||||
|
* (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization).
|
||||||
|
*
|
||||||
|
* Why both: the signup hooks are zero-latency happy-path bootstraps but
|
||||||
|
* fire-and-forget — a transient mana_sync outage during signup leaves
|
||||||
|
* the user with no singleton and no signal that anything is wrong. The
|
||||||
|
* boot-time endpoint converges to the right state on every load.
|
||||||
|
* Idempotency in the bootstrap functions makes double-invocation
|
||||||
|
* harmless.
|
||||||
|
*
|
||||||
|
* The endpoint is deliberately simple: no body, no parameters. The
|
||||||
|
* caller's identity (and thus the userId + space-membership list)
|
||||||
|
* comes from the JWT.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { logger } from '@mana/shared-hono';
|
||||||
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { members } from '../db/schema/organizations';
|
||||||
|
import {
|
||||||
|
bootstrapUserSingletons,
|
||||||
|
bootstrapSpaceSingletons,
|
||||||
|
} from '../services/bootstrap-singletons';
|
||||||
|
|
||||||
|
export interface BootstrapResponse {
|
||||||
|
ok: true;
|
||||||
|
bootstrapped: {
|
||||||
|
userContext: boolean;
|
||||||
|
spaces: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMeBootstrapRoutes(
|
||||||
|
db: Database,
|
||||||
|
syncDatabaseUrl: string
|
||||||
|
): Hono<{ Variables: { user: AuthUser } }> {
|
||||||
|
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
|
||||||
|
// better-auth.config.ts — process lifetime owns it; never closed
|
||||||
|
// manually.
|
||||||
|
let _syncSql: ReturnType<typeof postgres> | null = null;
|
||||||
|
const getSyncSql = (): ReturnType<typeof postgres> => {
|
||||||
|
if (!_syncSql) _syncSql = postgres(syncDatabaseUrl, { max: 2 });
|
||||||
|
return _syncSql;
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Hono<{ Variables: { user: AuthUser } }>().post('/', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const syncSql = getSyncSql();
|
||||||
|
|
||||||
|
const result: BootstrapResponse = {
|
||||||
|
ok: true,
|
||||||
|
bootstrapped: { userContext: false, spaces: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
result.bootstrapped.userContext = await bootstrapUserSingletons(user.userId, syncSql);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[me/bootstrap-singletons] userContext failed', {
|
||||||
|
userId: user.userId,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap every Space the user is a member of. The owner of a
|
||||||
|
// Space is the canonical writer for its singletons, but RLS
|
||||||
|
// only gates by user_id (writer); the membership-aware pull
|
||||||
|
// delivers the row to every member regardless of which member
|
||||||
|
// inserted it. If the owner's bootstrap failed at signup time
|
||||||
|
// and a non-owner member calls this endpoint first, the
|
||||||
|
// member's bootstrap stands in.
|
||||||
|
const memberRows = await db
|
||||||
|
.select({ organizationId: members.organizationId })
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, user.userId));
|
||||||
|
|
||||||
|
for (const row of memberRows) {
|
||||||
|
const spaceId = row.organizationId;
|
||||||
|
try {
|
||||||
|
result.bootstrapped.spaces[spaceId] = await bootstrapSpaceSingletons(
|
||||||
|
spaceId,
|
||||||
|
user.userId,
|
||||||
|
syncSql
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[me/bootstrap-singletons] space failed', {
|
||||||
|
userId: user.userId,
|
||||||
|
spaceId,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
// Don't abort — surface the per-space outcome and
|
||||||
|
// continue. The caller can retry on next boot.
|
||||||
|
result.bootstrapped.spaces[spaceId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -16,15 +16,20 @@
|
||||||
* empty default in plaintext, the client's `decryptRecord` skips
|
* empty default in plaintext, the client's `decryptRecord` skips
|
||||||
* non-envelope strings so this is safe).
|
* non-envelope strings so this is safe).
|
||||||
*
|
*
|
||||||
* Idempotency: `ON CONFLICT (...) DO NOTHING` on the sync_changes primary
|
* Idempotency: each function performs an existence-check on
|
||||||
* key would only catch re-inserts of the same row id, which never happens
|
* `sync_changes` before inserting — if a row matching the singleton's
|
||||||
* (UUIDs are fresh per call). Instead the caller MUST gate on the first
|
* scope already exists, the call is a no-op. This makes the bootstrap
|
||||||
* real creation event — the user-create hook fires once per real signup,
|
* safe to run from multiple sources without producing duplicate rows:
|
||||||
* and the personal-space + organization hooks fire once per real Space
|
* - signup-time hooks (databaseHooks.user.create.after,
|
||||||
* creation. Calling either bootstrap twice for the same target produces
|
* organizationHooks.afterCreateOrganization) — fire on the happy
|
||||||
* two insert rows; field-LWW replay collapses them on the client (latest
|
* path
|
||||||
* `at` wins). Harmless but wasteful, hence the `created: true` gate in
|
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires
|
||||||
* `createPersonalSpaceFor`'s caller.
|
* on every webapp boot as a reconciliation belt-and-suspenders
|
||||||
|
*
|
||||||
|
* The TOCTOU race between two concurrent callers can theoretically
|
||||||
|
* still produce a duplicate insert, but field-LWW collapses duplicates
|
||||||
|
* harmlessly on the client (latest `at` wins). The check is a
|
||||||
|
* waste-reduction, not a correctness mechanism.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
@ -97,18 +102,33 @@ function emptyKontextDocData(): Record<string, unknown> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the per-user singletons into mana_sync.sync_changes. Called
|
* Insert the per-user singletons into mana_sync.sync_changes. Idempotent
|
||||||
* fire-and-forget from the post-signUp hook in routes/auth.ts; failures
|
* — skips the insert if a row for `(userContext, 'singleton', userId)`
|
||||||
* are logged but do not abort registration (the webapp's
|
* already exists. Called from the post-signUp hook in routes/auth.ts and
|
||||||
* `getOrCreateLocalDoc()` is still in place as a fallback for the rare
|
* from the boot-time `/me/bootstrap-singletons` endpoint; both are
|
||||||
* race where the first pull hasn't landed yet).
|
* fire-and-forget at the caller, but the caller can also `await` it
|
||||||
|
* (the boot endpoint does) and report failure to the client without
|
||||||
|
* causing a write conflict.
|
||||||
|
*
|
||||||
|
* Returns true if an insert was actually written, false if the
|
||||||
|
* idempotency check skipped it.
|
||||||
*/
|
*/
|
||||||
export async function bootstrapUserSingletons(
|
export async function bootstrapUserSingletons(
|
||||||
userId: string,
|
userId: string,
|
||||||
syncSql: ReturnType<typeof postgres>
|
syncSql: ReturnType<typeof postgres>
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
if (!userId) throw new Error('bootstrapUserSingletons: empty userId');
|
if (!userId) throw new Error('bootstrapUserSingletons: empty userId');
|
||||||
|
|
||||||
|
const existing = await syncSql<Array<{ exists: number }>>`
|
||||||
|
SELECT 1 AS exists
|
||||||
|
FROM sync_changes
|
||||||
|
WHERE table_name = 'userContext'
|
||||||
|
AND record_id = 'singleton'
|
||||||
|
AND user_id = ${userId}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (existing.length > 0) return false;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const data = emptyUserContextData(userId);
|
const data = emptyUserContextData(userId);
|
||||||
const fieldMeta = buildFieldMeta(data, now);
|
const fieldMeta = buildFieldMeta(data, now);
|
||||||
|
|
@ -127,28 +147,47 @@ export async function bootstrapUserSingletons(
|
||||||
${BOOTSTRAP_ORIGIN}
|
${BOOTSTRAP_ORIGIN}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the per-Space singletons into mana_sync.sync_changes. Called
|
* Insert the per-Space singletons into mana_sync.sync_changes. Idempotent
|
||||||
* after every real Space creation:
|
* — skips the insert if any `kontextDoc` row already exists for the
|
||||||
* - Personal Space — from `databaseHooks.user.create.after` once
|
* given `spaceId` (regardless of writer). Called from:
|
||||||
* `createPersonalSpaceFor` returns `created: true`.
|
* - `databaseHooks.user.create.after` once `createPersonalSpaceFor`
|
||||||
* - Brand / club / family / team / practice — from
|
* returns `created: true` (personal-space happy path)
|
||||||
* `organizationHooks.afterCreateOrganization` on the org plugin.
|
* - `organizationHooks.afterCreateOrganization` (brand / club /
|
||||||
|
* family / team / practice happy path)
|
||||||
|
* - `POST /api/v1/me/bootstrap-singletons` for every space the
|
||||||
|
* caller is a member of (boot-time reconciliation)
|
||||||
*
|
*
|
||||||
* `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope.
|
* `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope.
|
||||||
* For non-personal Spaces the inviting user remains the writer — joining
|
* For non-personal Spaces the inviting user remains the writer — joining
|
||||||
* members will receive the row via the membership-aware pull.
|
* members will receive the row via the membership-aware pull. If the
|
||||||
|
* inviter's bootstrap somehow failed and a member triggers it later via
|
||||||
|
* the endpoint, the member becomes the writer; the row is still
|
||||||
|
* delivered to all members via the membership-aware pull.
|
||||||
|
*
|
||||||
|
* Returns true if an insert was actually written, false if the
|
||||||
|
* idempotency check skipped it.
|
||||||
*/
|
*/
|
||||||
export async function bootstrapSpaceSingletons(
|
export async function bootstrapSpaceSingletons(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
ownerUserId: string,
|
ownerUserId: string,
|
||||||
syncSql: ReturnType<typeof postgres>
|
syncSql: ReturnType<typeof postgres>
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
||||||
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
|
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
|
||||||
|
|
||||||
|
const existing = await syncSql<Array<{ exists: number }>>`
|
||||||
|
SELECT 1 AS exists
|
||||||
|
FROM sync_changes
|
||||||
|
WHERE table_name = 'kontextDoc'
|
||||||
|
AND space_id = ${spaceId}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (existing.length > 0) return false;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const data = emptyKontextDocData();
|
const data = emptyKontextDocData();
|
||||||
const fieldMeta = buildFieldMeta(data, now);
|
const fieldMeta = buildFieldMeta(data, now);
|
||||||
|
|
@ -167,4 +206,5 @@ export async function bootstrapSpaceSingletons(
|
||||||
${BOOTSTRAP_ORIGIN}
|
${BOOTSTRAP_ORIGIN}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue