mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(auth): bootstrap per-Space kontextDoc on Space-creation (F4 follow-up)
Symmetrically extends the F4 server-side singleton bootstrap to the per-Space `kontextDoc`. Every Space-creation — Personal at signup and brand/club/family/team/practice via the org plugin — now writes an empty kontextDoc row straight into mana_sync.sync_changes with origin='system', client_id='system:bootstrap'. Fresh clients pull the row instead of racing on a local insert that the next pull would clobber. - New `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in services/mana-auth/src/services/bootstrap-singletons.ts; shared `buildFieldMeta` helper extracted. - `createBetterAuth(databaseUrl, syncDatabaseUrl, webauthn)` now takes the sync-DB URL and lazy-creates a module-scoped postgres pool for the bootstrap inserts. - Hook into `databaseHooks.user.create.after` (only on `created: true` from createPersonalSpaceFor) and `organizationHooks.afterCreateOrganization`. - Webapp `kontextStore.ensureDoc()` made private as `getOrCreateLocalDoc()` — same fallback role as userContextStore's after F5. Public API is now just setContent + appendContent. Plan: docs/plans/sync-field-meta-overhaul.md (F4-fu row in Shipping Log). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bcf150ea16
commit
3df7391905
8 changed files with 197 additions and 66 deletions
|
|
@ -102,7 +102,7 @@ The four bug-roots that made the conflict-toast fire spuriously have all been cl
|
|||
- **`__fieldMeta`** (single hidden field per record, replaces the older `__fieldTimestamps` / `__fieldActors` / `__lastActor` triple). Shape: `{ [field]: { at, actor, origin } }`. The Dexie creating/updating hook stamps it on every write; consumers read it via `readFieldMeta()` and `deriveUpdatedAt()` from `$lib/data/sync`.
|
||||
- **Origin-tracking**: `originFromActor(actor)` in `@mana/shared-ai` maps `actor.kind` onto `'user' | 'agent' | 'system' | 'migration' | 'server-replay'`. The conflict surface fires only when `localFieldMeta.origin === 'user'` — replay-deltas from server pulls, agent writes, migration helpers, and bootstrap inserts never surface as toasts.
|
||||
- **`updatedAt` is no longer a synced data field.** Type-converters compute `updatedAt` on read as `max(__fieldMeta[*].at)` via `deriveUpdatedAt(local)`. For Dexie-indexed sort, every record carries a non-synced `_updatedAtIndex` shadow column that the hook stamps automatically — `orderBy('_updatedAtIndex')` instead of `orderBy('updatedAt')`.
|
||||
- **Server-side singleton bootstrap**: mana-auth's `/register` flow writes the `userContext` singleton straight into `mana_sync.sync_changes` with `origin: 'system'`. The webapp's `getOrCreateLocalDoc()` survives only as a fallback for the rare race where the first pull hasn't landed yet.
|
||||
- **Server-side singleton bootstrap**: mana-auth writes per-user and per-Space singletons straight into `mana_sync.sync_changes` with `origin: 'system'`. `userContext` (per-user) is bootstrapped from the `/register` flow; `kontextDoc` (per-Space) is bootstrapped from the personal-space hook in `databaseHooks.user.create.after` and from `organizationHooks.afterCreateOrganization` for every non-personal Space. The webapp's `getOrCreateLocalDoc()` survives in both stores only as a fallback for the rare race where the first pull hasn't landed yet.
|
||||
- **Stable `client_id`**: Dexie table `_clientIdentity` (single row keyed by `id='self'`) is the canonical source of the per-device sync identity. `restoreClientIdFromDexie()` runs once at boot and reconciles localStorage ↔ Dexie — a localStorage wipe gets restored from Dexie, the server keeps seeing the same client.
|
||||
|
||||
When writing new code:
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ Sieben Phasen, die vier strukturelle Bugs in der Conflict-Detection abgeräumt h
|
|||
- **F2** (`ad5e04a55`) Conflict-Trigger gated auf `localFieldMeta.origin === 'user' && !options.isInitialHydration`. `originFromActor()` in shared-ai mappt actor.kind → `'user' | 'agent' | 'system' | 'migration'`. applyServerChanges stempelt alle Replays als `'server-replay'`. Initial-Pull-Hydration suppress alle Conflicts (Belt-and-Suspenders).
|
||||
- **F3** (`6bb9d77be`) `updatedAt` raus aus dem Wire. Read-side computed via `deriveUpdatedAt(record) = max(__fieldMeta[*].at)`. Lokale `_updatedAtIndex`-Schatten-Spalte für Dexie-`orderBy`-Sortierung. v53 Dexie-Migration kopiert `updatedAt` → `_updatedAtIndex` für existing rows. ~382 stamping-sites über 121 Files entfernt.
|
||||
- **F4** (`c07db300b`) Server-side Singleton-Bootstrap in mana-auth's `/register`. `bootstrapUserSingletons(userId)` schreibt `userContext` direkt in `mana_sync.sync_changes` mit `client_id='system:bootstrap'` + `origin='system'`. Default-Inhalt mirror't `emptyUserContext()`.
|
||||
- **F4-fu (kontextDoc)** (_pending_) Symmetrische Variante für `kontextDoc` (per-Space, nicht per-user). `bootstrapSpaceSingletons(spaceId, ownerUserId)` läuft aus `databaseHooks.user.create.after` (Personal-Space) und aus `organizationHooks.afterCreateOrganization` (brand/club/family/team/practice). `createBetterAuth(databaseUrl, syncDatabaseUrl, webauthn)` bekommt jetzt eine zweite URL für den Sync-DB-Pool. Webapp `kontextStore.ensureDoc()` privat zu `getOrCreateLocalDoc()` umbenannt (Public-API: `setContent` + `appendContent`).
|
||||
- **F5** (`d78f57c04`) `userContextStore.ensureDoc()` Public-API entfernt. Internal `getOrCreateLocalDoc()` bleibt als Fallback für brand-new clients deren First-Pull noch nicht durch ist. UI mountet ohne ensureDoc-Race.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Kontext module — reactive query for the active-Space document.
|
||||
*
|
||||
* Content is encrypted at rest. Returns null until first write; the
|
||||
* view calls kontextStore.ensureDoc() on mount to materialise the row.
|
||||
* Content is encrypted at rest. Returns the row as soon as it's been
|
||||
* pulled from mana-sync (every Space-creation server-bootstraps an empty
|
||||
* kontextDoc — see `bootstrap-singletons.ts`); returns null only during
|
||||
* the brief window before the first pull lands or for legacy Spaces
|
||||
* created before the bootstrap shipped.
|
||||
*
|
||||
* Per-Space since Phase 2d.2: each Space has its own kontextDoc;
|
||||
* Personal-Space's legacy singleton row is matched by the in-scope
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@
|
|||
* Kontext Store — per-Space markdown document.
|
||||
*
|
||||
* Since Phase 2d.2 the module is Space-scoped: each Space has its own
|
||||
* kontextDoc. The store finds the row via `getInScopeSpaceIds()` (which
|
||||
* matches the active Space plus the legacy `_personal:<userId>` sentinel
|
||||
* so Personal-Space's pre-migration singleton row still renders).
|
||||
* kontextDoc. Since 2026-04-26 (sync-field-meta-overhaul Punkt 2) every
|
||||
* Space-creation also bootstraps an empty kontextDoc server-side via
|
||||
* `bootstrapSpaceSingletons` — fresh clients pull the row from mana-sync
|
||||
* instead of racing on a local insert. `getOrCreateLocalDoc()` below is
|
||||
* kept as a fallback for the brief window before the first pull lands
|
||||
* (and for legacy Spaces created before the bootstrap shipped).
|
||||
*
|
||||
* The store finds the row via `getInScopeSpaceIds()` (which matches the
|
||||
* active Space plus the legacy `_personal:<userId>` sentinel so
|
||||
* Personal-Space's pre-migration singleton row still renders).
|
||||
*
|
||||
* `content` is encrypted at rest. The Dexie creating hook stamps
|
||||
* `spaceId` on new rows automatically — we just pick a fresh UUID.
|
||||
|
|
@ -20,29 +27,31 @@ async function findForActiveSpace(): Promise<LocalKontextDoc | undefined> {
|
|||
return rows.find((r) => !r.deletedAt);
|
||||
}
|
||||
|
||||
export const kontextStore = {
|
||||
/**
|
||||
* Ensure a kontextDoc exists for the active Space. No-op if one
|
||||
* already exists. Returns the row so callers can read + write the
|
||||
* same id.
|
||||
*/
|
||||
async ensureDoc(): Promise<LocalKontextDoc> {
|
||||
const existing = await findForActiveSpace();
|
||||
if (existing) return existing;
|
||||
const newLocal: LocalKontextDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
await encryptRecord('kontextDoc', newLocal);
|
||||
await kontextDocTable.add(newLocal);
|
||||
// Reload — the creating-hook stamped spaceId/authorId/actor fields.
|
||||
const created = await kontextDocTable.get(newLocal.id);
|
||||
if (!created) throw new Error('Failed to create kontextDoc');
|
||||
return created;
|
||||
},
|
||||
/**
|
||||
* Fallback path for the rare race where a write happens before the
|
||||
* server-bootstrap row has reached the client. Materialises the row
|
||||
* locally so `setContent` / `appendContent` always have something to
|
||||
* update. The server-bootstrapped row will arrive on the next pull and
|
||||
* field-LWW collapses any concurrent writes.
|
||||
*/
|
||||
async function getOrCreateLocalDoc(): Promise<LocalKontextDoc> {
|
||||
const existing = await findForActiveSpace();
|
||||
if (existing) return existing;
|
||||
const newLocal: LocalKontextDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
await encryptRecord('kontextDoc', newLocal);
|
||||
await kontextDocTable.add(newLocal);
|
||||
// Reload — the creating-hook stamped spaceId/authorId/actor fields.
|
||||
const created = await kontextDocTable.get(newLocal.id);
|
||||
if (!created) throw new Error('Failed to create kontextDoc');
|
||||
return created;
|
||||
}
|
||||
|
||||
export const kontextStore = {
|
||||
async setContent(content: string): Promise<void> {
|
||||
const row = await this.ensureDoc();
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalKontextDoc> = {
|
||||
content,
|
||||
};
|
||||
|
|
@ -51,7 +60,7 @@ export const kontextStore = {
|
|||
},
|
||||
|
||||
async appendContent(chunk: string): Promise<void> {
|
||||
const row = await this.ensureDoc();
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [row]);
|
||||
const current = decrypted?.content ?? '';
|
||||
const separator = current.trim() ? '\n\n---\n\n' : '';
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ _Wird befüllt während der Ausführung._
|
|||
| F5 | `d78f57c04` | `userContextStore.ensureDoc()` aus der Public-API entfernt; die drei `void userContextStore.ensureDoc()` calls in ContextOverview/ContextInterview/ContextFreeform sind weg. Internal `getOrCreateLocalDoc()` bleibt als Fallback für brand-new clients deren Pull noch nicht durch ist. `kontextStore.ensureDoc()` bleibt — der ist per-Space, kein server-bootstrap. |
|
||||
| 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. |
|
||||
| F4-fu (kontextDoc) | _pending_ | 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). |
|
||||
|
||||
## F1 — Implementation Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { organization } from 'better-auth/plugins/organization';
|
|||
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||
import { passkey } from '@better-auth/passkey';
|
||||
import postgres from 'postgres';
|
||||
import { logger } from '@mana/shared-hono';
|
||||
import { getDb } from '../db/connection';
|
||||
import { organizations, members, invitations } from '../db/schema/organizations';
|
||||
import {
|
||||
|
|
@ -45,6 +47,7 @@ import {
|
|||
assertSpaceIsDeletable,
|
||||
createPersonalSpaceFor,
|
||||
} from '../spaces';
|
||||
import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons';
|
||||
|
||||
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
|
||||
// keep working. New code should import from './sso-origins' directly.
|
||||
|
|
@ -94,13 +97,30 @@ export interface BetterAuthWebAuthnOptions {
|
|||
/**
|
||||
* Create Better Auth instance
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL
|
||||
* @param databaseUrl - PostgreSQL connection URL for the auth DB
|
||||
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The
|
||||
* personal-space + organization hooks bootstrap per-Space singletons
|
||||
* into `sync_changes` so fresh clients pull the row instead of racing
|
||||
* on a local insert. See `bootstrapSpaceSingletons`.
|
||||
* @param webauthn - WebAuthn settings for the passkey plugin
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAuthnOptions) {
|
||||
export function createBetterAuth(
|
||||
databaseUrl: string,
|
||||
syncDatabaseUrl: string,
|
||||
webauthn: BetterAuthWebAuthnOptions
|
||||
) {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
// Lazy module-scoped sync SQL pool. Mirrors the pattern in
|
||||
// routes/auth.ts so we don't open a second pool just for the
|
||||
// org-create hook. 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 betterAuth({
|
||||
// Database adapter (Drizzle with PostgreSQL)
|
||||
database: drizzleAdapter(db, {
|
||||
|
|
@ -246,12 +266,30 @@ export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAut
|
|||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
await createPersonalSpaceFor(db, {
|
||||
const result = await createPersonalSpaceFor(db, {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
accessTier: (user as { accessTier?: string | null }).accessTier,
|
||||
});
|
||||
// Bootstrap the personal Space's kontextDoc only on a
|
||||
// real first-time creation. The `created: false` path
|
||||
// means a previous signup retry already provisioned it
|
||||
// and the doc has been bootstrapped before. Failures
|
||||
// are logged but do not abort signup — the webapp's
|
||||
// `ensureDoc()` fallback still creates the row on the
|
||||
// first write attempt.
|
||||
if (result.created) {
|
||||
bootstrapSpaceSingletons(result.organizationId, user.id, getSyncSql()).catch(
|
||||
(err: unknown) => {
|
||||
logger.error('[auth] bootstrapSpaceSingletons (personal) failed', {
|
||||
userId: user.id,
|
||||
organizationId: result.organizationId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -340,13 +378,32 @@ export function createBetterAuth(databaseUrl: string, webauthn: BetterAuthWebAut
|
|||
/**
|
||||
* Spaces — enforce that every organization carries a valid
|
||||
* `metadata.type` (the Space type), and block deletion of the
|
||||
* user's personal space. See docs/plans/spaces-foundation.md
|
||||
* user's personal space. After-create bootstraps per-Space
|
||||
* singletons (currently `kontextDoc`) into mana_sync so fresh
|
||||
* clients pull the row instead of racing on a local insert.
|
||||
* Personal-space gets the same bootstrap, but from
|
||||
* `databaseHooks.user.create.after` because Better Auth's
|
||||
* `afterCreateOrganization` does not fire on the implicit
|
||||
* personal-space creation that runs inside the user-create
|
||||
* hook (createPersonalSpaceFor writes to `organizations`
|
||||
* directly via Drizzle). See docs/plans/spaces-foundation.md
|
||||
* and ../spaces/metadata.ts.
|
||||
*/
|
||||
organizationHooks: {
|
||||
beforeCreateOrganization: async ({ organization }) => {
|
||||
assertValidSpaceMetadataForCreate(organization.metadata);
|
||||
},
|
||||
afterCreateOrganization: async ({ organization, user }) => {
|
||||
bootstrapSpaceSingletons(organization.id, user.id, getSyncSql()).catch(
|
||||
(err: unknown) => {
|
||||
logger.error('[auth] bootstrapSpaceSingletons (org-hook) failed', {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
beforeDeleteOrganization: async ({ organization }) => {
|
||||
assertSpaceIsDeletable(organization.metadata);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import { createInternalPersonasRoutes } from './routes/internal-personas';
|
|||
initLogger('mana-auth');
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
const auth = createBetterAuth(config.databaseUrl, config.webauthn);
|
||||
const auth = createBetterAuth(config.databaseUrl, config.syncDatabaseUrl, config.webauthn);
|
||||
|
||||
// Load the Key Encryption Key before any vault operation can run.
|
||||
// Top-level await is supported by Bun. Throws if MANA_AUTH_KEK is
|
||||
|
|
|
|||
|
|
@ -1,34 +1,33 @@
|
|||
/**
|
||||
* 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.
|
||||
* On first user-creation and Space-creation, write the singleton records
|
||||
* that the webapp would otherwise create on demand via `ensureDoc()` /
|
||||
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic — every
|
||||
* fresh client pulls the singleton from mana-sync instead of racing on a
|
||||
* local insert that the next pull would clobber.
|
||||
*
|
||||
* Currently bootstrapped:
|
||||
* - `userContext` — the structured profile + freeform markdown blob
|
||||
* keyed by `id='singleton'`. Default shape mirrors the webapp's
|
||||
* - `userContext` — per-user. The structured profile + freeform markdown
|
||||
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's
|
||||
* `emptyUserContext()` factory in `profile/types.ts`.
|
||||
* - `kontextDoc` — per-Space. The freeform markdown context document
|
||||
* (encrypted at rest in normal client writes; bootstrap writes the
|
||||
* empty default in plaintext, the client's `decryptRecord` skips
|
||||
* non-envelope strings so this is safe).
|
||||
*
|
||||
* 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.
|
||||
* 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 the first
|
||||
* real creation event — the user-create hook fires once per real signup,
|
||||
* and the personal-space + organization hooks fire once per real Space
|
||||
* creation. Calling either bootstrap twice for the same target produces
|
||||
* two insert rows; field-LWW replay collapses them on the client (latest
|
||||
* `at` wins). Harmless but wasteful, hence the `created: true` gate in
|
||||
* `createPersonalSpaceFor`'s caller.
|
||||
*/
|
||||
|
||||
import type postgres from 'postgres';
|
||||
import postgres from 'postgres';
|
||||
|
||||
interface Actor {
|
||||
kind: 'system';
|
||||
|
|
@ -45,6 +44,21 @@ const BOOTSTRAP_ACTOR: Actor = {
|
|||
const BOOTSTRAP_CLIENT_ID = 'system:bootstrap';
|
||||
const BOOTSTRAP_ORIGIN = 'system';
|
||||
|
||||
/**
|
||||
* Build a `field_meta` object for the bootstrap insert: every key in
|
||||
* `data` (except `id`) gets the same `at` timestamp. The receiving client
|
||||
* reads this column and populates `__fieldMeta[k] = { at, actor:
|
||||
* changeActor, origin: 'server-replay' }` — never surfaces as a conflict.
|
||||
*/
|
||||
function buildFieldMeta(data: Record<string, unknown>, at: string): Record<string, string> {
|
||||
const meta: Record<string, string> = {};
|
||||
for (const key of Object.keys(data)) {
|
||||
if (key === 'id') continue;
|
||||
meta[key] = at;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content for a new user's `userContext` singleton. Keep in sync
|
||||
* with `apps/mana/apps/web/src/lib/modules/profile/types.ts:emptyUserContext()`.
|
||||
|
|
@ -69,11 +83,25 @@ function emptyUserContextData(userId: string): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content for a new Space's `kontextDoc`. Just an id + empty
|
||||
* content — the user fills in the markdown later. Encryption is skipped
|
||||
* (empty string reveals nothing); the client's `decryptRecord` is
|
||||
* tolerant of plaintext values for encrypted-registry fields.
|
||||
*/
|
||||
function emptyKontextDocData(): Record<string, unknown> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* are logged but do not abort registration (the webapp's
|
||||
* `getOrCreateLocalDoc()` is still in place as a fallback for the rare
|
||||
* race where the first pull hasn't landed yet).
|
||||
*/
|
||||
export async function bootstrapUserSingletons(
|
||||
userId: string,
|
||||
|
|
@ -83,15 +111,7 @@ export async function bootstrapUserSingletons(
|
|||
|
||||
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;
|
||||
}
|
||||
const fieldMeta = buildFieldMeta(data, now);
|
||||
|
||||
await syncSql`
|
||||
INSERT INTO sync_changes (
|
||||
|
|
@ -108,3 +128,43 @@ export async function bootstrapUserSingletons(
|
|||
)
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the per-Space singletons into mana_sync.sync_changes. Called
|
||||
* after every real Space creation:
|
||||
* - Personal Space — from `databaseHooks.user.create.after` once
|
||||
* `createPersonalSpaceFor` returns `created: true`.
|
||||
* - Brand / club / family / team / practice — from
|
||||
* `organizationHooks.afterCreateOrganization` on the org plugin.
|
||||
*
|
||||
* `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope.
|
||||
* For non-personal Spaces the inviting user remains the writer — joining
|
||||
* members will receive the row via the membership-aware pull.
|
||||
*/
|
||||
export async function bootstrapSpaceSingletons(
|
||||
spaceId: string,
|
||||
ownerUserId: string,
|
||||
syncSql: ReturnType<typeof postgres>
|
||||
): Promise<void> {
|
||||
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
||||
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data = emptyKontextDocData();
|
||||
const fieldMeta = buildFieldMeta(data, 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 (
|
||||
'kontext', 'kontextDoc', ${data.id as string}, ${ownerUserId}, ${spaceId}, '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