mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(notes): isSpaceContext flag replaces kontext module (Option B)
Retire the kontext module entirely; the per-Space standing-context
document is now a regular Note flagged with `isSpaceContext: true`.
Daily use ("URL → Notiz") moves to the notes module as a first-class
action; the same primitive is reused by the (planned) Brand/Firma-Space
onboarding wizard to seed a Space-context Note from a URL.
Why: kontext was inconsistent — its UI was a URL-crawler that wrote
to userContext.freeform (profile module), while its kontextDoc table
+ AI-Mission-Runner auto-injection was a write-only shell with no
real editor. One concept (Notes) now carries both ad-hoc noting and
Space-context, with mutex (max 1 flagged Note per Space).
Notes module:
- types: add `isSpaceContext?: boolean` to LocalNote + Note
- queries: add `useSpaceContextNote()` (the active Space's flagged note)
- store: `markAsSpaceContext(id | null)` with mutex sweep across Space
- ListView: "Aus URL importieren" inline form (URL + crawl-mode +
KI-Zusammenfassung toggle); "Als Space-Kontext markieren" /
"Space-Kontext lösen" context-menu item; ★-Badge on flagged notes
- new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url
Notes API (apps/api):
- new modules/notes/routes.ts with /import-url (ported from kontext;
same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op)
- mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier)
- delete modules/context (UI-less /ai/generate + /ai/estimate had no
consumers; /import-url moved to notes)
- packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL
AI Mission Runner:
- default-resolvers: drop kontextResolver + kontextIndexer; the
notesIndexer flags `isSpaceContext` notes with "★ " prefix and
bubbles them to the top of the picker
- writing reference-resolver: `kind: 'kontext'` now reads the flagged
Note via scope-scan instead of the kontextDoc table; tests updated
- writing ReferencePicker: useSpaceContextNote replaces useKontextDoc
- AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop
'kontextDoc' from ENCRYPTED_SERVER_TABLES set
- ai-agents ListView: drop 'kontext' from POLICY_MODULES
Profile module:
- ContextFreeform.svelte: switch import from kontext/api to notes/api
(the URL-crawl is the same primitive; it still writes to
userContext.freeform — only the import path changed)
Dexie:
- v58: notes index gains `isSpaceContext`; kontextDoc table dropped
Kontext module deletion:
- delete apps/mana/apps/web/src/lib/modules/kontext/ entirely
- delete (app)/kontext/ route
- drop registerApp + Scroll icon from app-registry/apps.ts
- drop kontext entry from help-content
- drop kontextModuleConfig from data/module-registry.ts
- drop kontextDoc from crypto registry
mana-auth:
- bootstrap-singletons: drop bootstrapSpaceSingletons function entirely
(kontextDoc was the only per-Space singleton); userContext bootstrap
unchanged
- better-auth.config: drop kontextDoc bootstrap call from personal-space
hook + organizationHooks.afterCreateOrganization
- me-bootstrap: drop per-space bootstrap loop; response shape kept
(always-empty `spaces: {}`) for backwards-compat with older clients
Note: the still-existing legacy `context` module (CMS-style docs/spaces,
unrelated to kontext) is left in place; its cleanup landed on the
articles-bulk-import branch and is out of scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
054b9e5beb
commit
8fbdc6db77
37 changed files with 496 additions and 983 deletions
|
|
@ -47,7 +47,6 @@ 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.
|
||||
|
|
@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions {
|
|||
* Create Better Auth instance
|
||||
*
|
||||
* @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 syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held
|
||||
* for use by the per-user `userContext` bootstrap; currently no
|
||||
* per-Space singletons are written here (the kontextDoc that used to
|
||||
* live here was retired in the Option-B cleanup).
|
||||
* @param webauthn - WebAuthn settings for the passkey plugin
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
|
|
@ -272,24 +271,6 @@ export function createBetterAuth(
|
|||
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),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -378,32 +359,16 @@ export function createBetterAuth(
|
|||
/**
|
||||
* Spaces — enforce that every organization carries a valid
|
||||
* `metadata.type` (the Space type), and block deletion of the
|
||||
* 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.
|
||||
* user's personal space. The per-Space `kontextDoc` singleton
|
||||
* that used to be bootstrapped here was retired in favour of
|
||||
* the user-driven `notes.isSpaceContext` flag (Option B
|
||||
* cleanup), so the after-create hook is currently empty —
|
||||
* kept as a hook anchor for future per-Space bootstrap needs.
|
||||
*/
|
||||
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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
|
|||
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.
|
||||
// Idempotent reconciliation endpoint for the per-user `userContext`
|
||||
// singleton. Webapp boot calls this once; the signup-time hook remains
|
||||
// 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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,34 +2,34 @@
|
|||
* 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).
|
||||
* per-user `userContext` singleton. Called once per webapp boot as a
|
||||
* reconciliation belt-and-suspenders for the signup-time hook
|
||||
* (databaseHooks.user.create.after).
|
||||
*
|
||||
* Why both: the signup hooks are zero-latency happy-path bootstraps but
|
||||
* Why both: the signup hook is a zero-latency happy-path bootstrap 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
|
||||
* Idempotency in the bootstrap function 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.
|
||||
* caller's identity (and thus the userId) comes from the JWT.
|
||||
*
|
||||
* Per-Space singletons used to be bootstrapped here too (kontextDoc),
|
||||
* but the kontextDoc table was retired in favour of the user-driven
|
||||
* `notes.isSpaceContext` flag — there is nothing to bootstrap per
|
||||
* Space anymore. The response shape keeps the `spaces` map for
|
||||
* backwards compatibility with older webapp builds; it is always
|
||||
* empty now.
|
||||
*/
|
||||
|
||||
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';
|
||||
import { bootstrapUserSingletons } from '../services/bootstrap-singletons';
|
||||
|
||||
export interface BootstrapResponse {
|
||||
ok: true;
|
||||
|
|
@ -40,7 +40,7 @@ export interface BootstrapResponse {
|
|||
}
|
||||
|
||||
export function createMeBootstrapRoutes(
|
||||
db: Database,
|
||||
_db: Database,
|
||||
syncDatabaseUrl: string
|
||||
): Hono<{ Variables: { user: AuthUser } }> {
|
||||
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
|
||||
|
|
@ -71,38 +71,6 @@ export function createMeBootstrapRoutes(
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
/**
|
||||
* Server-side singleton bootstrap.
|
||||
*
|
||||
* 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.
|
||||
* On first user-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` — 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).
|
||||
*
|
||||
* Idempotency: each function performs an existence-check on
|
||||
* (The per-Space `kontextDoc` singleton was retired — the
|
||||
* notes.isSpaceContext flag now carries the same role, and a flagged
|
||||
* Note is created on demand by the user, not bootstrapped empty.)
|
||||
*
|
||||
* Idempotency: the function performs an existence-check on
|
||||
* `sync_changes` before inserting — if a row matching the singleton's
|
||||
* scope already exists, the call is a no-op. This makes the bootstrap
|
||||
* safe to run from multiple sources without producing duplicate rows:
|
||||
* - signup-time hooks (databaseHooks.user.create.after,
|
||||
* organizationHooks.afterCreateOrganization) — fire on the happy
|
||||
* path
|
||||
* - signup-time hook (databaseHooks.user.create.after) — fires on the
|
||||
* happy path
|
||||
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires
|
||||
* on every webapp boot as a reconciliation belt-and-suspenders
|
||||
*
|
||||
|
|
@ -88,19 +87,6 @@ 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. Idempotent
|
||||
* — skips the insert if a row for `(userContext, 'singleton', userId)`
|
||||
|
|
@ -149,62 +135,3 @@ export async function bootstrapUserSingletons(
|
|||
`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the per-Space singletons into mana_sync.sync_changes. Idempotent
|
||||
* — skips the insert if any `kontextDoc` row already exists for the
|
||||
* given `spaceId` (regardless of writer). Called from:
|
||||
* - `databaseHooks.user.create.after` once `createPersonalSpaceFor`
|
||||
* returns `created: true` (personal-space happy path)
|
||||
* - `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.
|
||||
* For non-personal Spaces the inviting user remains the writer — joining
|
||||
* 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(
|
||||
spaceId: string,
|
||||
ownerUserId: string,
|
||||
syncSql: ReturnType<typeof postgres>
|
||||
): Promise<boolean> {
|
||||
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
||||
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 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}
|
||||
)
|
||||
`;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue