fix(mana/web): sprint 2 — auth-aware data layer + guest migration

- Single source of truth for the active user via data/current-user.ts;
  layout pushes authStore.user.id into it on every auth state change.
- Dexie creating-hook auto-stamps userId from getEffectiveUserId(); the
  updating-hook strips userId from modifications so records are
  effectively user-immutable after creation.
- BaseRecord gains an optional userId so module types inherit it without
  per-module declarations. All hardcoded 'guest'/'local' fallbacks in
  module type-converters and session timer stores are deleted; the dead
  userId field is removed from the public view types where it was
  unused (Task, Conversation, Template, Deck, Plant, Contact, etc.).
- New guest-migration.ts: on first authenticated session, walks every
  sync-tracked table, deletes guest-owned records and re-adds them so
  the creating-hook re-stamps with the real user id and produces fresh
  insert pending-changes with the full payload. Stale guest pending-
  changes are cleared up-front.
- Drive-by: root onMount now returns its cleanup synchronously; the
  previous async form silently dropped the cleanup callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 13:07:12 +02:00
parent 0909538827
commit 28942abede
24 changed files with 182 additions and 47 deletions

View file

@ -0,0 +1,36 @@
/**
* Single source of truth for the current authenticated user id.
*
* Why a separate module?
* The data layer (database.ts hooks) needs to know who is writing each
* record so it can stamp `userId` automatically. Importing the auth store
* directly would couple the data layer to UI state and create a circular
* dependency. Instead, the root layout pushes the current user id here on
* every auth state change.
*
* Guest mode: when no user is signed in, records are stamped with the
* `GUEST_USER_ID` sentinel. The mana-sync backend treats these as anonymous
* and rejects them at the RLS layer once auth is required.
*/
export const GUEST_USER_ID = 'guest';
let currentUserId: string | null = null;
/** Updates the active user. Pass `null` for sign-out / guest. */
export function setCurrentUserId(id: string | null): void {
currentUserId = id;
}
/** Returns the active user id, or `null` if unauthenticated. */
export function getCurrentUserId(): string | null {
return currentUserId;
}
/**
* Returns the user id to stamp on local records: real user when signed in,
* `GUEST_USER_ID` otherwise. Always non-null so it can be used as a key.
*/
export function getEffectiveUserId(): string {
return currentUserId ?? GUEST_USER_ID;
}

View file

@ -11,6 +11,7 @@ import Dexie, { type EntityTable } from 'dexie';
import { trackFirstContent } from '$lib/stores/funnel-tracking';
import { fire as fireTrigger } from '$lib/triggers/registry';
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
import { getEffectiveUserId } from './current-user';
// ─── Database ──────────────────────────────────────────────
@ -576,6 +577,14 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
if (_applyingServerChanges) return;
const now = new Date().toISOString();
// Auto-stamp the active user. Module stores never set userId themselves,
// preventing accidental impersonation and removing all hardcoded
// 'guest'/'local' fallbacks scattered across query files.
const objRecord = obj as Record<string, unknown>;
if (objRecord.userId === undefined || objRecord.userId === null) {
objRecord.userId = getEffectiveUserId();
}
// Stamp every real field with the create-time so future LWW comparisons
// have a baseline. Mutates obj in place — Dexie persists the mutation.
const ft: Record<string, string> = {};
@ -583,7 +592,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
if (isInternalKey(key)) continue;
ft[key] = now;
}
(obj as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY] = ft;
objRecord[FIELD_TIMESTAMPS_KEY] = ft;
// Build payload for pending-change WITHOUT the internal timestamp map
const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record<string, unknown>;
@ -613,6 +622,12 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const now = new Date().toISOString();
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
// userId is immutable after creation. Silently strip any attempt to
// reassign it from a local update so a buggy or malicious caller can
// never re-parent records to a different user.
const mods = modifications as Record<string, unknown>;
if ('userId' in mods) delete mods.userId;
// Merge field timestamps: keep existing, overwrite for each modified field
const existingFT =
((obj as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY] as

View file

@ -0,0 +1,90 @@
/**
* Guest User data migration.
*
* In guest mode, the Dexie creating-hook stamps every record with
* `userId = GUEST_USER_ID`. Sync push is also a no-op while there is no
* auth token, so the records live purely in the local IndexedDB.
*
* When the user signs in for the first time we want their existing local
* data to be theirs. This module walks every sync-tracked table, finds
* records owned by `guest`, and re-creates them under the active user id.
*
* Why delete-and-re-add instead of an in-place update?
* The Dexie updating-hook deliberately strips `userId` from modifications
* (immutable once created), and even if we bypassed that, an `op: 'update'`
* pending-change would only ship the userId field to the server other
* clients pulling the change would see a fresh record with just an id and
* userId. By deleting and re-adding we generate a clean `op: 'insert'`
* with the full record payload.
*
* Deleting in guest mode is safe because nothing was ever pushed to the
* server: `_pendingChanges` is cleared as part of the migration too, so the
* delete is purely local and never reaches the sync layer.
*/
import { db, SYNC_APP_MAP, FIELD_TIMESTAMPS_KEY } from './database';
import { GUEST_USER_ID } from './current-user';
export interface GuestMigrationResult {
migratedRecords: number;
tablesTouched: number;
}
/**
* Re-stamps every `userId === 'guest'` record under the active user id.
*
* Caller must ensure `setCurrentUserId(newUserId)` was already invoked so
* that the Dexie creating-hook picks up the right id when re-inserting.
*/
export async function migrateGuestDataToUser(newUserId: string): Promise<GuestMigrationResult> {
if (!newUserId || newUserId === GUEST_USER_ID) {
return { migratedRecords: 0, tablesTouched: 0 };
}
// Drop any pending changes accumulated during guest mode — they were
// never pushed (no auth token) and reference the old guest userId. The
// re-inserts below will produce fresh pending changes that should NOT be
// wiped, so this MUST happen before the migration loop.
await db.table('_pendingChanges').clear();
let migratedRecords = 0;
let tablesTouched = 0;
for (const tables of Object.values(SYNC_APP_MAP)) {
for (const tableName of tables) {
const table = db.table(tableName);
// Filter scan: userId is not indexed (and we don't want to widen the
// schema for a one-shot migration). Tables are typically small at
// this point because guest mode only stores what one person typed.
const guestRecords = await table
.filter((r: unknown) => (r as Record<string, unknown>).userId === GUEST_USER_ID)
.toArray();
if (guestRecords.length === 0) continue;
tablesTouched++;
// One transaction per table keeps the delete+add pair atomic and
// avoids leaving the table half-migrated if Dexie throws partway.
await db.transaction('rw', table, async () => {
for (const oldRecord of guestRecords) {
const record = oldRecord as Record<string, unknown>;
const id = record.id as string;
// Strip the bookkeeping fields the creating-hook will rebuild.
// Importantly, drop `userId` so the hook stamps the new id from
// getEffectiveUserId() instead of preserving 'guest'.
const { userId: _oldUser, [FIELD_TIMESTAMPS_KEY]: _oldFt, ...clean } = record;
void _oldUser;
void _oldFt;
await table.delete(id);
await table.add({ ...clean, id });
migratedRecords++;
}
});
}
}
return { migratedRecords, tablesTouched };
}

View file

@ -12,7 +12,7 @@ import type { Calculation, SavedFormula } from '@calc/shared';
export function toCalculation(local: LocalCalculation): Calculation {
return {
id: local.id,
userId: 'local',
userId: local.userId ?? '',
mode: local.mode,
expression: local.expression,
result: local.result,
@ -24,7 +24,7 @@ export function toCalculation(local: LocalCalculation): Calculation {
export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
return {
id: local.id,
userId: 'local',
userId: local.userId ?? '',
name: local.name,
expression: local.expression,
description: local.description ?? undefined,

View file

@ -13,7 +13,6 @@ import type { LocalDeck, LocalCard, Deck, Card } from './types';
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.name,
description: local.description ?? undefined,
color: local.color,

View file

@ -27,7 +27,6 @@ export interface LocalCard extends BaseRecord {
export interface Deck {
id: string;
userId: string;
title: string;
description?: string;
color: string;

View file

@ -18,7 +18,6 @@ import type {
export function toConversation(local: LocalConversation): Conversation {
return {
id: local.id,
userId: local.userId ?? 'guest',
modelId: local.modelId ?? '',
templateId: local.templateId ?? undefined,
spaceId: local.spaceId ?? undefined,
@ -35,7 +34,6 @@ export function toConversation(local: LocalConversation): Conversation {
export function toTemplate(local: LocalTemplate): Template {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
description: local.description || null,
systemPrompt: local.systemPrompt,

View file

@ -36,7 +36,6 @@ export interface LocalTemplate extends BaseRecord {
export interface Conversation {
id: string;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
@ -60,7 +59,6 @@ export interface Message {
export interface Template {
id: string;
userId: string;
name: string;
description: string | null;
systemPrompt: string;

View file

@ -15,7 +15,6 @@ export function toContact(local: LocalContact): Contact {
return {
id: local.id,
userId: 'local',
firstName,
lastName,
displayName,

View file

@ -35,7 +35,6 @@ export interface LocalContact extends BaseRecord {
export interface Contact {
id: string;
userId: string;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;

View file

@ -7,7 +7,6 @@ import type { BaseRecord } from '@mana/local-store';
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface LocalMemo extends BaseRecord {
userId?: string;
title: string | null;
intro: string | null;
transcript: string | null;
@ -45,7 +44,6 @@ export interface LocalMemo extends BaseRecord {
export interface LocalMemory extends BaseRecord {
memoId: string;
userId?: string;
title: string;
content: string | null;
metadata?: Record<string, unknown>;

View file

@ -25,7 +25,6 @@ import type {
export function toPlant(local: LocalPlant): Plant {
return {
id: local.id,
userId: 'local',
name: local.name,
scientificName: local.scientificName ?? undefined,
commonName: local.commonName ?? undefined,
@ -49,7 +48,6 @@ export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
storagePath: local.storagePath,
publicUrl: local.publicUrl ?? undefined,
filename: local.filename,
@ -69,7 +67,6 @@ export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedu
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
frequencyDays: local.frequencyDays,
lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined,
nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined,
@ -85,7 +82,6 @@ export function toWateringLog(local: LocalWateringLog): WateringLog {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
wateredAt: new Date(local.wateredAt),
notes: local.notes ?? undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),

View file

@ -62,7 +62,6 @@ export interface LocalWateringLog extends BaseRecord {
export interface Plant {
id: string;
userId: string;
name: string;
scientificName?: string;
commonName?: string;
@ -83,7 +82,6 @@ export interface Plant {
export interface PlantPhoto {
id: string;
plantId: string;
userId: string;
storagePath: string;
publicUrl?: string;
filename: string;
@ -100,7 +98,6 @@ export interface PlantPhoto {
export interface WateringSchedule {
id: string;
plantId: string;
userId: string;
frequencyDays: number;
lastWateredAt?: Date;
nextWateringAt?: Date;
@ -113,7 +110,6 @@ export interface WateringSchedule {
export interface WateringLog {
id: string;
plantId: string;
userId: string;
wateredAt: Date;
notes?: string;
createdAt: Date;

View file

@ -14,7 +14,6 @@ import type { LocalDeck, LocalSlide, Deck, Slide } from './types';
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,

View file

@ -30,7 +30,6 @@ export interface SlideContent {
export interface Deck {
id: string;
userId: string;
title: string;
description?: string;
themeId?: string;

View file

@ -12,7 +12,6 @@ import type { LocalFile, LocalFolder, LocalFileTag } from './types';
export interface StorageFile {
id: string;
userId: string;
name: string;
originalName: string;
mimeType: string;
@ -30,7 +29,6 @@ export interface StorageFile {
export interface StorageFolder {
id: string;
userId: string;
name: string;
description: string | null;
color: string | null;
@ -46,7 +44,6 @@ export interface StorageFolder {
export interface StorageTag {
id: string;
userId: string;
name: string;
color: string | null;
createdAt: string;
@ -57,7 +54,6 @@ export interface StorageTag {
export function toFile(local: LocalFile): StorageFile {
return {
id: local.id,
userId: 'local',
name: local.name,
originalName: local.originalName,
mimeType: local.mimeType,
@ -77,7 +73,6 @@ export function toFile(local: LocalFile): StorageFile {
export function toFolder(local: LocalFolder): StorageFolder {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? null,
color: local.color ?? null,
@ -100,7 +95,6 @@ export function toTag(local: {
}): StorageTag {
return {
id: local.id,
userId: 'local',
name: local.name,
color: local.color ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),

View file

@ -130,7 +130,6 @@ export function toSettings(local: LocalSettings): TimesSettings {
export function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
@ -146,7 +145,6 @@ export function toAlarm(local: LocalAlarm): Alarm {
export function toCountdownTimer(local: LocalCountdownTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
@ -162,7 +160,6 @@ export function toCountdownTimer(local: LocalCountdownTimer): Timer {
export function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,

View file

@ -62,7 +62,6 @@ export const sessionAlarmsStore = {
const now = new Date().toISOString();
const alarm: Alarm = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
time: input.time,
enabled: input.enabled ?? true,

View file

@ -63,7 +63,6 @@ export const sessionCountdownTimersStore = {
const now = new Date().toISOString();
const timer: Timer = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
durationSeconds: input.durationSeconds,
remainingSeconds: input.durationSeconds,

View file

@ -251,7 +251,6 @@ export type TimerStatus = 'idle' | 'running' | 'paused' | 'finished';
export interface Alarm {
id: string;
userId: string;
label: string | null;
time: string; // HH:MM:SS format
enabled: boolean;
@ -285,7 +284,6 @@ export interface UpdateAlarmInput {
export interface Timer {
id: string;
userId: string;
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
@ -311,7 +309,6 @@ export interface UpdateTimerInput {
export interface WorldClock {
id: string;
userId: string;
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;

View file

@ -20,7 +20,6 @@ export function toTask(local: LocalTask): Task {
return {
id: local.id,
projectId: (local as Record<string, unknown>).projectId as string | null | undefined,
userId: local.userId ?? 'guest',
title: local.title,
description: local.description,
dueDate: local.dueDate,

View file

@ -24,7 +24,6 @@ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface LocalTask extends BaseRecord {
title: string;
description?: string;
userId?: string;
projectId?: string | null;
priority: TaskPriority;
isCompleted: boolean;
@ -45,7 +44,6 @@ export interface LocalTaskTag extends BaseRecord {
export interface LocalReminder extends BaseRecord {
taskId: string;
userId?: string;
minutesBefore: number;
type: 'push' | 'email' | 'both';
status: 'pending' | 'sent' | 'failed';
@ -101,7 +99,6 @@ export interface LocalTodoProject extends BaseRecord {
export interface Task {
id: string;
projectId?: string | null;
userId: string;
title: string;
description?: string | null;
dueDate?: string | null;

View file

@ -5,24 +5,51 @@
import { authStore } from '$lib/stores/auth.svelte';
import { networkStore } from '$lib/stores/network.svelte';
import { loadAutomations } from '$lib/triggers';
import { setCurrentUserId } from '$lib/data/current-user';
import { migrateGuestDataToUser } from '$lib/data/guest-migration';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
let { children } = $props();
onMount(async () => {
// Tracks whether we have already attempted the guest → user migration in
// this app load. The migration is idempotent (no guest records → no-op)
// so this just prevents redundant table scans on every auth state change.
let guestMigrationAttempted = false;
// Push the active user id into the data layer whenever auth state changes.
// The Dexie creating-hook reads this to auto-stamp `userId` on every record,
// so module stores never need to know who the current user is.
$effect(() => {
const userId = authStore.user?.id ?? null;
setCurrentUserId(userId);
// First time we see an authenticated user in this session, lift any
// guest records into their account so the data they typed before
// signing up follows them.
if (userId && !guestMigrationAttempted) {
guestMigrationAttempted = true;
migrateGuestDataToUser(userId).catch((err) => {
console.error('[mana] guest → user migration failed:', err);
});
}
});
onMount(() => {
// Initialize theme
const cleanupTheme = theme.initialize();
// Initialize network status tracking
networkStore.initialize();
// Initialize auth
await authStore.initialize();
// Load cross-module automation triggers
await loadAutomations();
// Auth + automation loading is async — fire and forget. Returning
// cleanup from an async onMount would silently drop it, so the async
// work runs in an inner IIFE while the outer arrow stays sync.
void (async () => {
await authStore.initialize();
await loadAutomations();
})();
return () => {
cleanupTheme();

View file

@ -5,6 +5,11 @@
/** Base record that all local-store entities must extend. */
export interface BaseRecord {
id: string;
/**
* Owner of this record. Auto-stamped by the Dexie creating-hook from the
* active session user; module stores must never set this themselves.
*/
userId?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;