diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts index 002da78cb..8d8d6ccf0 100644 --- a/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts +++ b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts @@ -27,7 +27,6 @@ import { encryptRecord } from '../../crypto'; import type { Mission } from '../missions/types'; import { MISSIONS_TABLE } from '../missions/types'; import { getActiveSpace } from '../../scope/active-space.svelte'; -import { getEffectiveSpaceId } from '../../scope/scoped-db'; import { DEFAULT_AI_POLICY } from '../policy'; import { getAgent } from './store'; import type { Agent } from './types'; @@ -106,7 +105,7 @@ export async function ensureDefaultAgent(): Promise { const toWrite: Agent = { ...agent }; await encryptRecord(AGENTS_TABLE, toWrite); try { - await db.table(AGENTS_TABLE).add({ ...toWrite, spaceId: getEffectiveSpaceId() }); + await db.table(AGENTS_TABLE).add(toWrite); } catch (err) { // Race: another tab just wrote the same id. Re-fetch and return // that tab's record. diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts b/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts index 5300f95e8..9d6eaf545 100644 --- a/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts +++ b/apps/mana/apps/web/src/lib/data/ai/agents/kontext.ts @@ -9,7 +9,6 @@ import { db } from '../../database'; import { encryptRecord, decryptRecords } from '../../crypto'; -import { getEffectiveSpaceId } from '../../scope/scoped-db'; const TABLE = 'agentKontextDocs'; @@ -66,6 +65,6 @@ export async function saveAgentKontext(agentId: string, content: string): Promis content, }; await encryptRecord(TABLE, doc); - await db.table(TABLE).add({ ...doc, spaceId: getEffectiveSpaceId() }); + await db.table(TABLE).add(doc); } } diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 79c68051f..ec2db680c 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -19,6 +19,7 @@ import { trackFirstContent } from '$lib/stores/funnel-tracking'; import { fire as fireTrigger } from '$lib/triggers/registry'; import { checkInlineSuggestion } from '$lib/triggers/inline-suggest'; import { getEffectiveUserId, GUEST_USER_ID } from './current-user'; +import { getEffectiveSpaceId } from './scope/active-space.svelte'; import { getCurrentActor } from './events/actor'; import type { Actor } from './events/actor'; import { isQuotaError, notifyQuotaExceeded } from './quota-detect'; @@ -1119,6 +1120,30 @@ db.version(48).upgrade(async (tx) => { } }); +// v49 — Comic-Character sub-system (docs/plans/comic-module.md §11). +// Space-scoped sibling table to comicStories: a `LocalComicCharacter` +// row groups N variant renderings of "the user as a comic-style +// character" generated via gpt-image-2 / Nano Banana edits over the +// raw face/body meImages with a comic-style prefix. One pinned variant +// is the canonical look; stories snapshot that variant's mediaId at +// story-create time so re-pinning later doesn't rewrite history. +// +// Why space-scoped (not user-scoped): the source meImages this builds +// on are themselves space-scoped after v40. A character generated in +// the personal space references face/body refs that don't exist in +// the brand space, so making the character user-global would orphan +// references on space-switch. Same scoping rationale as wardrobe- +// garments — derived assets travel with their source. +// +// Indices: +// - createdAt for "newest first" grid ordering +// - style for style-tab filtering on /comic/character (M5 list-tool) +// - isFavorite for the favorites filter +// - isArchived for the standard archive-hide filter +db.version(49).stores({ + comicCharacters: 'id, createdAt, style, isFavorite, isArchived', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module @@ -1340,17 +1365,21 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { // (see v36 below). Skipping the stamps here keeps future // rows clean. } else { - // Auto-stamp the Space-scope fields on data tables. Until - // the scope bootstrap (see `./scope/active-space.svelte.ts`) - // resolves the user's personal-space id from Better Auth, - // new records carry a deterministic sentinel - // `_personal:` that the bootstrap rewrites in a - // single pass. Module stores set spaceId explicitly once - // they start writing into non-personal spaces — this stamp - // only fills the gap. Sentinel uses `effectiveUserId` - // directly since `userId` isn't on data records anymore. + // Auto-stamp Space-scope fields on data tables. The hook + // resolves `getEffectiveSpaceId()` which returns the + // currently-active Space's id — so a calendar event + // created during a Brand-Space session lands under that + // Brand UUID, not under Personal. During the bootstrap + // window before `loadActiveSpace` resolves, the helper + // falls back to the sentinel `_personal:` which + // `reconcileSentinels` rewrites once the real id is known. + // + // Stores can set `spaceId` explicitly when writing to a + // space they're not currently active in (e.g. workbench- + // home seeder writes to a target Space's id, not the + // active one) — the hook only fills in the gap. if (objRecord.spaceId === undefined || objRecord.spaceId === null) { - objRecord.spaceId = `_personal:${effectiveUserId}`; + objRecord.spaceId = getEffectiveSpaceId(); } if (objRecord.authorId === undefined || objRecord.authorId === null) { objRecord.authorId = effectiveUserId; diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index f69dc4fdd..41aeaff68 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -13,6 +13,7 @@ import type { SpaceType, SpaceTier } from '@mana/shared-types'; import { isSpaceType, isSpaceTier } from '@mana/shared-types'; import { authFetch } from './auth-fetch'; import { runSpaceSeeds } from './per-space-seeds'; +import { getEffectiveUserId } from '../current-user'; export interface ActiveSpace { id: string; @@ -79,6 +80,29 @@ export function getActiveSpaceId(): string | null { return active?.id ?? null; } +/** + * The spaceId every new write to a space-scoped table should carry. + * Used by the Dexie creating-hook to auto-stamp tenancy on records the + * caller hasn't explicitly assigned. Returns: + * + * - the active Space's id when one is loaded (e.g. ``, + * ``, …) — every write goes to the right tenant + * - the personal sentinel `_personal:` during the bootstrap + * window before `loadActiveSpace` resolves, so writes never block + * and `reconcileSentinels` rewrites the placeholder once the real + * id is known. + * + * Inlined sentinel helper instead of importing `personalSpaceSentinel` + * from `./bootstrap` because bootstrap.ts pulls in `db` and the + * creating-hook in `database.ts` imports this function — the indirect + * cycle would tangle ESM resolution. + */ +export function getEffectiveSpaceId(): string { + const id = active?.id; + if (id) return id; + return `_personal:${getEffectiveUserId()}`; +} + export function getActiveSpaceStatus(): ActiveSpaceStatus { return status; } diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts index 5634722e6..6b7ad9e63 100644 --- a/apps/mana/apps/web/src/lib/data/scope/index.ts +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -11,6 +11,7 @@ export { getActiveSpace, getActiveSpaceId, + getEffectiveSpaceId, getActiveSpaceStatus, getActiveSpaceError, setActiveSpace, @@ -29,7 +30,6 @@ export { scopedGet, assertModuleAllowed, getInScopeSpaceIds, - getEffectiveSpaceId, ScopeNotReadyError, ModuleNotInSpaceError, } from './scoped-db'; diff --git a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts index f9d6b0b3e..5ced28151 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -66,25 +66,6 @@ export function getInScopeSpaceIds(): string[] { return [sentinel]; } -/** - * The spaceId every new write to a space-scoped table should carry. - * Returns the active Space's id when one is loaded, falling back to the - * personal sentinel `_personal:` for guests / pre-bootstrap - * windows. The sentinel value matches what `reconcileSentinels` rewrites - * to the real personal-space id once `loadActiveSpace` resolves, so no - * row gets stranded. - * - * Module stores call this and stamp `spaceId` on the record explicitly - * before `.add()` / `.put()`. Once Schicht A flips the creating-hook to - * throw on missing spaceId (see docs/plans/workbench-seeding-cleanup.md), - * forgetting this call is a hard error instead of silent corruption. - */ -export function getEffectiveSpaceId(): string { - const active = getActiveSpaceId(); - if (active) return active; - return personalSpaceSentinel(getEffectiveUserId()); -} - /** * Return a Collection that applies the space filter — chainable with any * further `.where()`, `.filter()`, `.toArray()`, `.modify()`. diff --git a/apps/mana/apps/web/src/lib/modules/calc/stores/calculations.svelte.ts b/apps/mana/apps/web/src/lib/modules/calc/stores/calculations.svelte.ts index 1643030b4..60da43def 100644 --- a/apps/mana/apps/web/src/lib/modules/calc/stores/calculations.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calc/stores/calculations.svelte.ts @@ -4,19 +4,17 @@ import { db } from '$lib/data/database'; import { CalcEvents } from '@mana/shared-utils/analytics'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalCalculation } from '../types'; import type { CreateCalculationInput } from '@calc/shared'; export const calculationsStore = { async addCalculation(input: CreateCalculationInput) { - await db.table('calculations').add({ + await db.table('calculations').add({ id: crypto.randomUUID(), mode: input.mode, expression: input.expression, result: input.result, skin: input.skin, - spaceId: getEffectiveSpaceId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/apps/mana/apps/web/src/lib/modules/calc/stores/saved-formulas.svelte.ts b/apps/mana/apps/web/src/lib/modules/calc/stores/saved-formulas.svelte.ts index 940e0e81d..79bae1ed8 100644 --- a/apps/mana/apps/web/src/lib/modules/calc/stores/saved-formulas.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calc/stores/saved-formulas.svelte.ts @@ -4,19 +4,17 @@ import { db } from '$lib/data/database'; import { CalcEvents } from '@mana/shared-utils/analytics'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalSavedFormula } from '../types'; import type { CreateFormulaInput, UpdateFormulaInput } from '@calc/shared'; export const savedFormulasStore = { async saveFormula(input: CreateFormulaInput) { - await db.table('savedFormulas').add({ + await db.table('savedFormulas').add({ id: crypto.randomUUID(), name: input.name, expression: input.expression, description: input.description ?? null, mode: input.mode, - spaceId: getEffectiveSpaceId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/apps/mana/apps/web/src/lib/modules/companion/stores/chat.svelte.ts b/apps/mana/apps/web/src/lib/modules/companion/stores/chat.svelte.ts index e499a6bfc..a1a9f9850 100644 --- a/apps/mana/apps/web/src/lib/modules/companion/stores/chat.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/companion/stores/chat.svelte.ts @@ -8,7 +8,6 @@ import { db } from '$lib/data/database'; import { emitDomainEvent } from '$lib/data/events'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalConversation, LocalMessage } from '../types'; const CONV_TABLE = 'companionConversations'; @@ -25,7 +24,7 @@ export const chatStore = { createdAt: now, updatedAt: now, }; - await db.table(CONV_TABLE).add({ ...conv, spaceId: getEffectiveSpaceId() }); + await db.table(CONV_TABLE).add(conv); emitDomainEvent('CompanionConversationStarted', 'companion', CONV_TABLE, conv.id, { conversationId: conv.id, title: conv.title, @@ -67,7 +66,7 @@ export const chatStore = { toolResult: extra?.toolResult, createdAt: new Date().toISOString(), }; - await db.table(MSG_TABLE).add({ ...msg, spaceId: getEffectiveSpaceId() }); + await db.table(MSG_TABLE).add(msg); // Touch conversation updatedAt await db.table(CONV_TABLE).update(conversationId, { diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts index f982dbe84..fce43f15d 100644 --- a/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/events/stores/guests.svelte.ts @@ -4,7 +4,6 @@ import { db } from '$lib/data/database'; import { encryptRecord } from '$lib/data/crypto'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalEventGuest, RsvpStatus } from '../types'; let error = $state(null); @@ -45,7 +44,7 @@ export const eventGuestsStore = { // records stay local-only — they're never pushed to the // public RSVP snapshot, so no decrypt-before-publish here. await encryptRecord('eventGuests', newGuest); - await db.table('eventGuests').add({ ...newGuest, spaceId: getEffectiveSpaceId() }); + await db.table('eventGuests').add(newGuest); return { success: true as const, id }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to add guest'; diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts index 2f565f5e9..2ce6146b0 100644 --- a/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts @@ -4,7 +4,6 @@ */ import { db } from '$lib/data/database'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalEventItem } from '../types'; import { eventsStore } from './events.svelte'; @@ -48,7 +47,7 @@ export const eventItemsStore = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; - await db.table('eventItems').add({ ...newItem, spaceId: getEffectiveSpaceId() }); + await db.table('eventItems').add(newItem); void eventsStore.syncItems(input.eventId); return { success: true as const, id }; } catch (e) { diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts b/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts index d5a9d4d80..02458f5c3 100644 --- a/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/moodlit/stores/moods.svelte.ts @@ -5,7 +5,6 @@ import { db } from '$lib/data/database'; import { MoodlitEvents } from '@mana/shared-utils/analytics'; import { createBlock, updateBlock } from '$lib/data/time-blocks/service'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalMood } from '../types'; import type { Mood, MoodSettings } from '../types'; @@ -129,13 +128,12 @@ function createMoodsStore() { // IndexedDB mutation methods async createMood(data: { name: string; colors: string[]; animation: string }) { - await db.table('moods').add({ + await db.table('moods').add({ id: crypto.randomUUID(), name: data.name, colors: data.colors, animation: data.animation, isDefault: false, - spaceId: getEffectiveSpaceId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts b/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts index 80b7bc779..365ff25bb 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/stores/boards.svelte.ts @@ -9,7 +9,7 @@ import { db } from '$lib/data/database'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; -import { getActiveSpace, getEffectiveSpaceId } from '$lib/data/scope'; +import { getActiveSpace } from '$lib/data/scope'; import { getEffectiveUserId } from '$lib/data/current-user'; import { defaultVisibilityFor, @@ -56,7 +56,7 @@ export const boardsStore = { // mutates `newLocal` in place — UI consumers expect plaintext. const plaintextSnapshot = toBoard({ ...newLocal }); await encryptRecord('boards', newLocal); - await db.table('boards').add({ ...newLocal, spaceId: getEffectiveSpaceId() }); + await db.table('boards').add(newLocal); return { success: true, data: plaintextSnapshot }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to create board'; @@ -170,7 +170,7 @@ export const boardsStore = { updatedAt: now, }; await encryptRecord('boardItems', newItem); - await db.table('boardItems').add({ ...newItem, spaceId: getEffectiveSpaceId() }); + await db.table('boardItems').add(newItem); } return { success: true, data: plaintextSnapshot }; diff --git a/apps/mana/apps/web/src/lib/modules/plants/mutations.ts b/apps/mana/apps/web/src/lib/modules/plants/mutations.ts index e92311f79..685c5b792 100644 --- a/apps/mana/apps/web/src/lib/modules/plants/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/plants/mutations.ts @@ -11,7 +11,6 @@ import { PlantsEvents } from '@mana/shared-utils/analytics'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; import { createBlock } from '$lib/data/time-blocks/service'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api'; import type { LocalPlant, @@ -195,7 +194,6 @@ export const wateringMutations = { nextWateringAt: nextDate.toISOString(), reminderEnabled: false, reminderHoursBefore: 0, - spaceId: getEffectiveSpaceId(), createdAt: now, updatedAt: now, }); diff --git a/apps/mana/apps/web/src/lib/modules/questions/stores/answers.svelte.ts b/apps/mana/apps/web/src/lib/modules/questions/stores/answers.svelte.ts index 84a21428f..d08e3946f 100644 --- a/apps/mana/apps/web/src/lib/modules/questions/stores/answers.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/questions/stores/answers.svelte.ts @@ -22,7 +22,6 @@ import { db } from '$lib/data/database'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import { researchApi, type ResearchEvent, type ResearchSource } from '$lib/api/research'; import type { LocalAnswer, LocalQuestion } from '../types'; @@ -44,7 +43,6 @@ async function createManual(input: CreateManualAnswerInput): Promise { citations: [], rating: null, isAccepted: false, - spaceId: getEffectiveSpaceId(), createdAt: now, updatedAt: now, }; @@ -109,8 +107,6 @@ async function startResearch(opts: StartResearchOptions): Promise { const now = new Date().toISOString(); const id = `custom-${crypto.randomUUID()}`; - await db.table('customQuotes').add({ + await db.table('customQuotes').add({ id, text: input.text, author: input.author, category: input.category ?? null, source: input.source ?? null, year: input.year ?? null, - spaceId: getEffectiveSpaceId(), createdAt: now, updatedAt: now, }); diff --git a/apps/mana/apps/web/src/lib/modules/quotes/stores/favorites.svelte.ts b/apps/mana/apps/web/src/lib/modules/quotes/stores/favorites.svelte.ts index 796b55781..5f9813e2c 100644 --- a/apps/mana/apps/web/src/lib/modules/quotes/stores/favorites.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/quotes/stores/favorites.svelte.ts @@ -5,17 +5,15 @@ */ import { db } from '$lib/data/database'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { LocalFavorite } from '../types'; import type { Favorite } from '../queries'; export const favoritesStore = { async add(quoteId: string) { const now = new Date().toISOString(); - await db.table('quotesFavorites').add({ + await db.table('quotesFavorites').add({ id: crypto.randomUUID(), quoteId, - spaceId: getEffectiveSpaceId(), createdAt: now, updatedAt: now, }); diff --git a/apps/mana/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts b/apps/mana/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts index 94991b27c..21159e78a 100644 --- a/apps/mana/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/skilltree/stores/achievements.svelte.ts @@ -6,7 +6,6 @@ */ import { db } from '$lib/data/database'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { AchievementWithStatus, AchievementUnlockResult, @@ -96,14 +95,13 @@ async function seedIfEmpty() { const active = stored.filter((a) => !a.deletedAt); if (active.length === 0) { for (const def of ACHIEVEMENT_DEFINITIONS) { - await db.table('achievements').add({ + await db.table('achievements').add({ id: def.id, key: def.id, name: def.name, description: def.description, icon: def.icon, unlockedAt: '', - spaceId: getEffectiveSpaceId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts b/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts index 53415d45a..8c633aff2 100644 --- a/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts @@ -7,7 +7,6 @@ import { db } from '$lib/data/database'; import { emitDomainEvent } from '$lib/data/events'; -import { getEffectiveSpaceId } from '$lib/data/scope'; import type { Skill } from '../types'; import { calculateLevel, createDefaultSkill, createActivity } from '../types'; import type { LocalSkill, LocalActivity } from '../types'; @@ -30,9 +29,8 @@ async function addSkill(data: Partial): Promise { totalXp: skill.totalXp, level: skill.level, }; - await db.table('skills').add({ + await db.table('skills').add({ ...localSkill, - spaceId: getEffectiveSpaceId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), });