feat: use a local database per user

This commit is contained in:
openclaw 2026-07-25 02:12:02 +08:00
parent 508e63aca2
commit b750dc3511
5 changed files with 91 additions and 4 deletions

View File

@ -18,7 +18,7 @@ export type LocalExchangeRate = {
updatedAt: string;
};
export const db = new Dexie("cents") as Dexie & {
export type CentsDatabase = Dexie & {
entries: EntityTable<LedgerEntry, "id">;
syncOperations: EntityTable<SyncOperation, "id">;
ledgers: EntityTable<LedgerRecord, "id">;
@ -27,6 +27,7 @@ export const db = new Dexie("cents") as Dexie & {
exchangeRates: EntityTable<LocalExchangeRate, "id">;
};
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<string, CentsDatabase>();
const databaseOpeners = new Map<string, Promise<CentsDatabase>>();
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);

View File

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

View File

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

View File

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

View File

@ -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,