mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
0909538827
commit
28942abede
24 changed files with 182 additions and 47 deletions
36
apps/mana/apps/web/src/lib/data/current-user.ts
Normal file
36
apps/mana/apps/web/src/lib/data/current-user.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
90
apps/mana/apps/web/src/lib/data/guest-migration.ts
Normal file
90
apps/mana/apps/web/src/lib/data/guest-migration.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export interface LocalCard extends BaseRecord {
|
|||
|
||||
export interface Deck {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export function toContact(local: LocalContact): Contact {
|
|||
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ export interface SlideContent {
|
|||
|
||||
export interface Deck {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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();
|
||||
|
||||
// Load cross-module automation triggers
|
||||
await loadAutomations();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cleanupTheme();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue