refactor(scope): smart hook stamps active-Space id, revert explicit stamps

Replaces the silent `_personal:<userId>` literal in the creating-hook
with `getEffectiveSpaceId()`, which returns the currently-active Space
when one is loaded and falls back to the personal sentinel for
guests / pre-bootstrap windows. Side-effect: writes during a Brand /
Family / Team session now land under that Space's UUID instead of
silently routing to Personal once `reconcileSentinels` runs — the
underlying tenancy bug Schicht A was supposed to catch.

With the hook doing the right thing automatically, the 16 explicit
`spaceId: getEffectiveSpaceId()` stamps from Etappe 1 are redundant
boilerplate. Reverted across:

  picture/stores/boards (boards + boardItems)
  events/stores/{guests,items}
  companion/stores/chat (conversations + messages)
  calc/stores/{calculations,saved-formulas}
  quotes/stores/{favorites,custom-quotes}
  skilltree/stores/{skills,achievements}
  moodlit/stores/moods
  plants/mutations
  questions/stores/answers (manual + research draft)
  data/ai/agents/{bootstrap,kontext}

Helper plumbing:

- `getEffectiveSpaceId()` lives in `scope/active-space.svelte.ts` (no
  db dependency) so the creating-hook in `database.ts` can import it
  without an ESM cycle. Inlined the `_personal:<userId>` literal there
  instead of pulling `personalSpaceSentinel` from `bootstrap.ts`,
  which would otherwise tangle the import graph.
- Re-exported via `scope/index.ts` for callers outside the hook.
- `setActiveSpace` and `loadActiveSpace` already funnel through the
  shared `applyActiveSpace` helper, so the hook's view of the active
  Space stays in sync with the rest of the scope layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:49:42 +02:00
parent 9e04385930
commit a6c5397d10
19 changed files with 80 additions and 71 deletions

View file

@ -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<Agent> {
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.

View file

@ -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<LocalAgentKontextDoc>(TABLE).add(doc);
}
}

View file

@ -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:<userId>` 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:<userId>` 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;

View file

@ -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. `<personal-uuid>`,
* `<brand-uuid>`, ) every write goes to the right tenant
* - the personal sentinel `_personal:<userId>` 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;
}

View file

@ -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';

View file

@ -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:<userId>` 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()`.

View file

@ -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<LocalCalculation>('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(),
});

View file

@ -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<LocalSavedFormula>('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(),
});

View file

@ -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<LocalConversation>(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<LocalMessage>(MSG_TABLE).add(msg);
// Touch conversation updatedAt
await db.table<LocalConversation>(CONV_TABLE).update(conversationId, {

View file

@ -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<string | null>(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<LocalEventGuest>('eventGuests').add(newGuest);
return { success: true as const, id };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add guest';

View file

@ -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<LocalEventItem>('eventItems').add(newItem);
void eventsStore.syncItems(input.eventId);
return { success: true as const, id };
} catch (e) {

View file

@ -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<LocalMood>('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(),
});

View file

@ -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<LocalBoard>('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<LocalBoardItem>('boardItems').add(newItem);
}
return { success: true, data: plaintextSnapshot };

View file

@ -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,
});

View file

@ -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<string> {
citations: [],
rating: null,
isAccepted: false,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
};
@ -109,8 +107,6 @@ async function startResearch(opts: StartResearchOptions): Promise<ResearchHandle
content: '',
citations: [],
rating: null,
isAccepted: false,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
};

View file

@ -4,7 +4,6 @@
*/
import { db } from '$lib/data/database';
import { getEffectiveSpaceId } from '$lib/data/scope';
import type { LocalCustomQuote } from '../types';
export interface CustomQuoteInput {
@ -19,14 +18,13 @@ export const customQuotesStore = {
async create(input: CustomQuoteInput): Promise<string> {
const now = new Date().toISOString();
const id = `custom-${crypto.randomUUID()}`;
await db.table('customQuotes').add({
await db.table<LocalCustomQuote>('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,
});

View file

@ -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<LocalFavorite>('quotesFavorites').add({
id: crypto.randomUUID(),
quoteId,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
});

View file

@ -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<LocalAchievement>('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(),
});

View file

@ -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<Skill>): Promise<Skill> {
totalXp: skill.totalXp,
level: skill.level,
};
await db.table('skills').add({
await db.table<LocalSkill>('skills').add({
...localSkill,
spaceId: getEffectiveSpaceId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});