diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 1205ade4c..003ea5be5 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -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: diff --git a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md index 4888b51bf..b28ff584c 100644 --- a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md +++ b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md @@ -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. diff --git a/apps/mana/apps/web/src/lib/modules/kontext/queries.ts b/apps/mana/apps/web/src/lib/modules/kontext/queries.ts index 9ebe01481..0b4cbf394 100644 --- a/apps/mana/apps/web/src/lib/modules/kontext/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/kontext/queries.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts b/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts index 58e001240..97fc00b2b 100644 --- a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts @@ -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:` 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:` 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 { 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 { - 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 { + 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 { - const row = await this.ensureDoc(); + const row = await getOrCreateLocalDoc(); const diff: Partial = { content, }; @@ -51,7 +60,7 @@ export const kontextStore = { }, async appendContent(chunk: string): Promise { - 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' : ''; diff --git a/docs/plans/sync-field-meta-overhaul.md b/docs/plans/sync-field-meta-overhaul.md index cca2ae1ec..dc7ee6a3c 100644 --- a/docs/plans/sync-field-meta-overhaul.md +++ b/docs/plans/sync-field-meta-overhaul.md @@ -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 diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index 4caf87e16..c551ad160 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -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 | null = null; + const getSyncSql = (): ReturnType => { + 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); }, diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 0e87671b7..026328d5f 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -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 diff --git a/services/mana-auth/src/services/bootstrap-singletons.ts b/services/mana-auth/src/services/bootstrap-singletons.ts index 917e4c211..5420a6c4d 100644 --- a/services/mana-auth/src/services/bootstrap-singletons.ts +++ b/services/mana-auth/src/services/bootstrap-singletons.ts @@ -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, at: string): Record { + const meta: Record = {}; + 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 { }; } +/** + * 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 { + 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 = {}; - 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 +): Promise { + 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} + ) + `; +}