refactor(scope): explicit spaceId stamping at every space-scoped write

Schicht A Etappe 1 of the workbench-seeding-cleanup plan: every module
store that writes to a space-scoped Dexie table now sets `spaceId`
explicitly via `getEffectiveSpaceId()` instead of relying on the
creating-hook's silent auto-stamp. Etappe 2 (flip the hook to throw on
missing spaceId) follows after a soak day.

New helper:

- `data/scope/scoped-db.ts` — `getEffectiveSpaceId()` returns the
  active Space's id when one is loaded, falling back to the personal
  sentinel `_personal:<userId>` for guests / pre-bootstrap windows.
  Symmetric with `getEffectiveUserId()`. Re-exported from
  `data/scope/index.ts`.

Migrated call sites (16 writes across 10 modules):

  picture: boards, boardItems
  events:  eventGuests, eventItems
  companion: companionConversations, companionMessages
  calc:    savedFormulas, calculations
  quotes:  quotesFavorites, customQuotes
  skilltree: skills, achievements
  moodlit: moods
  plants:  wateringSchedules
  questions: answers (manual + research-driven paths)
  data/ai: agents, agentKontextDocs

The audit also flagged `_serverIterationExecutions`, but it's an
internal infra table (leading underscore, not in SYNC_APP_MAP, never
synced) — the creating-hook doesn't run on it, so no change needed.

No behaviour change today: the hook still falls through to sentinel-
stamping when spaceId is missing, so existing rows + any unmigrated
caller keep working. Etappe 2 flips that fallback to a hard error,
turning silent omissions into write-time failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:11:25 +02:00
parent 42828434a4
commit 43bef2b24b
17 changed files with 60 additions and 16 deletions

View file

@ -27,6 +27,7 @@ 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';
@ -105,7 +106,7 @@ export async function ensureDefaultAgent(): Promise<Agent> {
const toWrite: Agent = { ...agent };
await encryptRecord(AGENTS_TABLE, toWrite);
try {
await db.table(AGENTS_TABLE).add(toWrite);
await db.table(AGENTS_TABLE).add({ ...toWrite, spaceId: getEffectiveSpaceId() });
} catch (err) {
// Race: another tab just wrote the same id. Re-fetch and return
// that tab's record.

View file

@ -9,6 +9,7 @@
import { db } from '../../database';
import { encryptRecord, decryptRecords } from '../../crypto';
import { getEffectiveSpaceId } from '../../scope/scoped-db';
const TABLE = 'agentKontextDocs';
@ -65,6 +66,6 @@ export async function saveAgentKontext(agentId: string, content: string): Promis
content,
};
await encryptRecord(TABLE, doc);
await db.table<LocalAgentKontextDoc>(TABLE).add(doc);
await db.table(TABLE).add({ ...doc, spaceId: getEffectiveSpaceId() });
}
}

View file

@ -29,6 +29,7 @@ export {
scopedGet,
assertModuleAllowed,
getInScopeSpaceIds,
getEffectiveSpaceId,
ScopeNotReadyError,
ModuleNotInSpaceError,
} from './scoped-db';

View file

@ -66,6 +66,25 @@ 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,17 +4,19 @@
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<LocalCalculation>('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(),
});

View file

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

View file

@ -8,6 +8,7 @@
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';
@ -24,7 +25,7 @@ export const chatStore = {
createdAt: now,
updatedAt: now,
};
await db.table<LocalConversation>(CONV_TABLE).add(conv);
await db.table(CONV_TABLE).add({ ...conv, spaceId: getEffectiveSpaceId() });
emitDomainEvent('CompanionConversationStarted', 'companion', CONV_TABLE, conv.id, {
conversationId: conv.id,
title: conv.title,
@ -66,7 +67,7 @@ export const chatStore = {
toolResult: extra?.toolResult,
createdAt: new Date().toISOString(),
};
await db.table<LocalMessage>(MSG_TABLE).add(msg);
await db.table(MSG_TABLE).add({ ...msg, spaceId: getEffectiveSpaceId() });
// Touch conversation updatedAt
await db.table<LocalConversation>(CONV_TABLE).update(conversationId, {

View file

@ -4,6 +4,7 @@
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);
@ -44,7 +45,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<LocalEventGuest>('eventGuests').add(newGuest);
await db.table('eventGuests').add({ ...newGuest, spaceId: getEffectiveSpaceId() });
return { success: true as const, id };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add guest';

View file

@ -4,6 +4,7 @@
*/
import { db } from '$lib/data/database';
import { getEffectiveSpaceId } from '$lib/data/scope';
import type { LocalEventItem } from '../types';
import { eventsStore } from './events.svelte';
@ -47,7 +48,7 @@ export const eventItemsStore = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table<LocalEventItem>('eventItems').add(newItem);
await db.table('eventItems').add({ ...newItem, spaceId: getEffectiveSpaceId() });
void eventsStore.syncItems(input.eventId);
return { success: true as const, id };
} catch (e) {

View file

@ -5,6 +5,7 @@
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';
@ -128,12 +129,13 @@ function createMoodsStore() {
// IndexedDB mutation methods
async createMood(data: { name: string; colors: string[]; animation: string }) {
await db.table<LocalMood>('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(),
});

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 } from '$lib/data/scope';
import { getActiveSpace, getEffectiveSpaceId } 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<LocalBoard>('boards').add(newLocal);
await db.table('boards').add({ ...newLocal, spaceId: getEffectiveSpaceId() });
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<LocalBoardItem>('boardItems').add(newItem);
await db.table('boardItems').add({ ...newItem, spaceId: getEffectiveSpaceId() });
}
return { success: true, data: plaintextSnapshot };

View file

@ -11,6 +11,7 @@ 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,
@ -194,6 +195,7 @@ export const wateringMutations = {
nextWateringAt: nextDate.toISOString(),
reminderEnabled: false,
reminderHoursBefore: 0,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
});

View file

@ -22,6 +22,7 @@
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';
@ -43,6 +44,7 @@ async function createManual(input: CreateManualAnswerInput): Promise<string> {
citations: [],
rating: null,
isAccepted: false,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
};
@ -108,6 +110,7 @@ async function startResearch(opts: StartResearchOptions): Promise<ResearchHandle
citations: [],
rating: null,
isAccepted: false,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
};

View file

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

View file

@ -5,15 +5,17 @@
*/
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<LocalFavorite>('quotesFavorites').add({
await db.table('quotesFavorites').add({
id: crypto.randomUUID(),
quoteId,
spaceId: getEffectiveSpaceId(),
createdAt: now,
updatedAt: now,
});

View file

@ -6,6 +6,7 @@
*/
import { db } from '$lib/data/database';
import { getEffectiveSpaceId } from '$lib/data/scope';
import type {
AchievementWithStatus,
AchievementUnlockResult,
@ -95,13 +96,14 @@ async function seedIfEmpty() {
const active = stored.filter((a) => !a.deletedAt);
if (active.length === 0) {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await db.table<LocalAchievement>('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(),
});

View file

@ -7,6 +7,7 @@
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';
@ -29,8 +30,9 @@ async function addSkill(data: Partial<Skill>): Promise<Skill> {
totalXp: skill.totalXp,
level: skill.level,
};
await db.table<LocalSkill>('skills').add({
await db.table('skills').add({
...localSkill,
spaceId: getEffectiveSpaceId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});