From b750dc3511f91a310319572694e58625e1341ee2 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sat, 25 Jul 2026 02:12:02 +0800 Subject: [PATCH] feat: use a local database per user --- apps/web/src/data/db.ts | 74 +++++++++++++++++++++++++++++++++- apps/web/src/data/ledgers.ts | 2 +- apps/web/src/stores/auth.ts | 3 ++ apps/web/src/stores/entries.ts | 11 ++++- apps/web/src/stores/ledgers.ts | 5 ++- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/web/src/data/db.ts b/apps/web/src/data/db.ts index 9cb9d81..0f580e0 100644 --- a/apps/web/src/data/db.ts +++ b/apps/web/src/data/db.ts @@ -18,7 +18,7 @@ export type LocalExchangeRate = { updatedAt: string; }; -export const db = new Dexie("cents") as Dexie & { +export type CentsDatabase = Dexie & { entries: EntityTable; syncOperations: EntityTable; ledgers: EntityTable; @@ -27,6 +27,7 @@ export const db = new Dexie("cents") as Dexie & { exchangeRates: EntityTable; }; +function configureDatabase(db: CentsDatabase) { db.version(1).stores({ entries: "id, ledgerId, occurredAt, updatedAt, deletedAt", syncOperations: "id, ledgerId, createdAt, syncedAt", @@ -107,6 +108,77 @@ db.version(5).stores({ normalizeEntry(operation.payload); }); }); +} + +const databaseCache = new Map(); +const databaseOpeners = new Map>(); + +export async function getUserDb(userId: string) { + const name = `cents-user-${userId}`; + const cached = databaseCache.get(name); + if (cached) return cached; + const opening = databaseOpeners.get(name); + if (opening) return opening; + const promise = (async () => { + const database = new Dexie(name) as CentsDatabase; + configureDatabase(database); + await database.open(); + await migrateLegacyUserData(database, userId); + databaseCache.set(name, database); + return database; + })(); + databaseOpeners.set(name, promise); + try { + return await promise; + } finally { + databaseOpeners.delete(name); + } +} + +export function closeUserDb(userId: string) { + const name = `cents-user-${userId}`; + const database = databaseCache.get(name); + database?.close(); + databaseCache.delete(name); +} + +async function migrateLegacyUserData(database: CentsDatabase, userId: string) { + const markerId = `${userId}:legacy-migration-v1`; + if (await database.userPreferences.get(markerId)) return; + const legacy = new Dexie("cents") as CentsDatabase; + configureDatabase(legacy); + try { + await legacy.open(); + const [entries, operations, preferences, exchangeRates] = await Promise.all([ + legacy.entries.filter((entry) => entry.ownerId === userId).toArray(), + legacy.syncOperations.filter((operation) => (operation.userId ?? operation.payload.updatedBy) === userId).toArray(), + legacy.userPreferences.filter((preference) => preference.userId === userId).toArray(), + legacy.exchangeRates.toArray(), + ]); + const operationEntries = await Promise.all( + operations.map((operation) => legacy.entries.get(operation.entityId)), + ); + const mergedEntries = new Map(entries.map((entry) => [entry.id, entry])); + for (const entry of operationEntries) { + if (entry) mergedEntries.set(entry.id, entry); + } + await database.transaction("rw", database.entries, database.syncOperations, database.userPreferences, database.exchangeRates, async () => { + if (mergedEntries.size) await database.entries.bulkPut([...mergedEntries.values()]); + if (operations.length) await database.syncOperations.bulkPut(operations); + if (preferences.length) await database.userPreferences.bulkPut(preferences); + if (exchangeRates.length) await database.exchangeRates.bulkPut(exchangeRates); + await database.userPreferences.put({ + id: markerId, + userId, + key: "legacyMigration", + value: "v1", + updatedAt: new Date().toISOString(), + }); + }); + } finally { + legacy.close(); + } +} function localDate(value: string) { const date = new Date(value); diff --git a/apps/web/src/data/ledgers.ts b/apps/web/src/data/ledgers.ts index 557c9ee..4e243e0 100644 --- a/apps/web/src/data/ledgers.ts +++ b/apps/web/src/data/ledgers.ts @@ -41,7 +41,7 @@ export type LedgerRecord = { export type UserPreference = { id: string; userId: string; - key: "currentLedgerId" | "ledgerAmountDisplay"; + key: "currentLedgerId" | "ledgerAmountDisplay" | "legacyMigration"; value: string; updatedAt: string; }; diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 99baf43..fa7d741 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { apiRequest } from "../data/api"; +import { closeUserDb } from "../data/db"; export type SessionUser = { id: string; name: string }; @@ -50,7 +51,9 @@ export const useAuthStore = defineStore("auth", { this.unavailable = false; }, async logout() { + const userId = this.user?.id; await apiRequest("/api/auth/logout", { method: "POST" }); + if (userId) closeUserDb(userId); this.user = null; this.loaded = true; }, diff --git a/apps/web/src/stores/entries.ts b/apps/web/src/stores/entries.ts index a3d0096..3eb968c 100644 --- a/apps/web/src/stores/entries.ts +++ b/apps/web/src/stores/entries.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain"; import { apiRequest } from "../data/api"; -import { db } from "../data/db"; +import { getUserDb } from "../data/db"; import { localDate } from "../data/entries"; import { createId } from "../data/ids"; import { useAuthStore } from "./auth"; @@ -76,6 +76,7 @@ export const useEntryStore = defineStore("entries", { this.loadedForUserId = ""; return; } + const db = await getUserDb(auth.user.id); const allowedLedgerIds = new Set(useLedgerStore().ledgers.map((ledger) => ledger.id)); const canAccessEntry = (entry: LedgerEntry) => entry.ownerId === auth.user!.id || entry.ledgerIds.some((ledgerId) => allowedLedgerIds.has(ledgerId)); @@ -113,6 +114,7 @@ export const useEntryStore = defineStore("entries", { return; } const userId = auth.user.id; + const db = await getUserDb(userId); if (syncPromise) return syncPromise; syncPromise = (async () => { @@ -184,6 +186,7 @@ export const useEntryStore = defineStore("entries", { }, async addEntry(entry: LedgerEntry) { const auth = useAuthStore(); + const db = await getUserDb(auth.user?.id ?? entry.ownerId); const operation: SyncOperation = { id: createId(), userId: auth.user?.id, @@ -207,6 +210,7 @@ export const useEntryStore = defineStore("entries", { }, async updateEntry(entry: LedgerEntry) { const auth = useAuthStore(); + const db = await getUserDb(auth.user?.id ?? entry.ownerId); const existing = await db.entries.get(entry.id); if (!existing || existing.deletedAt) throw new Error("Entry not found"); @@ -256,6 +260,9 @@ export const useEntryStore = defineStore("entries", { return updated; }, async resolveLocalConversion(entryId: string) { + const auth = useAuthStore(); + if (!auth.user) return; + const db = await getUserDb(auth.user.id); const entry = await db.entries.get(entryId); if (!entry || entry.deletedAt || entry.currency === "CNY") return; const cacheId = `${entry.currency}:${entry.exchangeRateDate}`; @@ -322,6 +329,8 @@ export const useEntryStore = defineStore("entries", { }, async deleteEntry(entryId: string) { const auth = useAuthStore(); + if (!auth.user) return; + const db = await getUserDb(auth.user.id); const existing = await db.entries.get(entryId); if (!existing || existing.deletedAt) return; diff --git a/apps/web/src/stores/ledgers.ts b/apps/web/src/stores/ledgers.ts index fafd19d..36acaf9 100644 --- a/apps/web/src/stores/ledgers.ts +++ b/apps/web/src/stores/ledgers.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { apiRequest } from "../data/api"; -import { db } from "../data/db"; +import { getUserDb } from "../data/db"; import { currentLedgerPreferenceId, ledgerAmountDisplayPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers"; import type { LedgerAmountDisplayMode } from "@cents/domain"; import { useAuthStore } from "./auth"; @@ -39,6 +39,7 @@ export const useLedgerStore = defineStore("ledgers", { ledgerLoadPromise = (async () => { const auth = useAuthStore(); if (!auth.user) return; + const db = await getUserDb(auth.user.id); const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers"); const displayPreferences = await db.userPreferences .where("userId") @@ -91,6 +92,7 @@ export const useLedgerStore = defineStore("ledgers", { async persistCurrentLedger() { const auth = useAuthStore(); if (!this.currentLedgerId || !auth.user) return; + const db = await getUserDb(auth.user.id); const preference: UserPreference = { id: currentLedgerPreferenceId(auth.user.id), userId: auth.user.id, @@ -125,6 +127,7 @@ export const useLedgerStore = defineStore("ledgers", { if (amountDisplay) { const auth = useAuthStore(); if (auth.user) { + const db = await getUserDb(auth.user.id); const preference: UserPreference = { id: ledgerAmountDisplayPreferenceId(auth.user.id, input.id), userId: auth.user.id,