feat: use a local database per user
This commit is contained in:
parent
508e63aca2
commit
b750dc3511
|
|
@ -18,7 +18,7 @@ export type LocalExchangeRate = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = new Dexie("cents") as Dexie & {
|
export type CentsDatabase = Dexie & {
|
||||||
entries: EntityTable<LedgerEntry, "id">;
|
entries: EntityTable<LedgerEntry, "id">;
|
||||||
syncOperations: EntityTable<SyncOperation, "id">;
|
syncOperations: EntityTable<SyncOperation, "id">;
|
||||||
ledgers: EntityTable<LedgerRecord, "id">;
|
ledgers: EntityTable<LedgerRecord, "id">;
|
||||||
|
|
@ -27,6 +27,7 @@ export const db = new Dexie("cents") as Dexie & {
|
||||||
exchangeRates: EntityTable<LocalExchangeRate, "id">;
|
exchangeRates: EntityTable<LocalExchangeRate, "id">;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function configureDatabase(db: CentsDatabase) {
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
entries: "id, ledgerId, occurredAt, updatedAt, deletedAt",
|
entries: "id, ledgerId, occurredAt, updatedAt, deletedAt",
|
||||||
syncOperations: "id, ledgerId, createdAt, syncedAt",
|
syncOperations: "id, ledgerId, createdAt, syncedAt",
|
||||||
|
|
@ -107,6 +108,77 @@ db.version(5).stores({
|
||||||
normalizeEntry(operation.payload);
|
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) {
|
function localDate(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export type LedgerRecord = {
|
||||||
export type UserPreference = {
|
export type UserPreference = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
key: "currentLedgerId" | "ledgerAmountDisplay";
|
key: "currentLedgerId" | "ledgerAmountDisplay" | "legacyMigration";
|
||||||
value: string;
|
value: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { apiRequest } from "../data/api";
|
import { apiRequest } from "../data/api";
|
||||||
|
import { closeUserDb } from "../data/db";
|
||||||
|
|
||||||
export type SessionUser = { id: string; name: string };
|
export type SessionUser = { id: string; name: string };
|
||||||
|
|
||||||
|
|
@ -50,7 +51,9 @@ export const useAuthStore = defineStore("auth", {
|
||||||
this.unavailable = false;
|
this.unavailable = false;
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
|
const userId = this.user?.id;
|
||||||
await apiRequest("/api/auth/logout", { method: "POST" });
|
await apiRequest("/api/auth/logout", { method: "POST" });
|
||||||
|
if (userId) closeUserDb(userId);
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain";
|
import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain";
|
||||||
import { apiRequest } from "../data/api";
|
import { apiRequest } from "../data/api";
|
||||||
import { db } from "../data/db";
|
import { getUserDb } from "../data/db";
|
||||||
import { localDate } from "../data/entries";
|
import { localDate } from "../data/entries";
|
||||||
import { createId } from "../data/ids";
|
import { createId } from "../data/ids";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
|
|
@ -76,6 +76,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
this.loadedForUserId = "";
|
this.loadedForUserId = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const db = await getUserDb(auth.user.id);
|
||||||
const allowedLedgerIds = new Set(useLedgerStore().ledgers.map((ledger) => ledger.id));
|
const allowedLedgerIds = new Set(useLedgerStore().ledgers.map((ledger) => ledger.id));
|
||||||
const canAccessEntry = (entry: LedgerEntry) =>
|
const canAccessEntry = (entry: LedgerEntry) =>
|
||||||
entry.ownerId === auth.user!.id || entry.ledgerIds.some((ledgerId) => allowedLedgerIds.has(ledgerId));
|
entry.ownerId === auth.user!.id || entry.ledgerIds.some((ledgerId) => allowedLedgerIds.has(ledgerId));
|
||||||
|
|
@ -113,6 +114,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const userId = auth.user.id;
|
const userId = auth.user.id;
|
||||||
|
const db = await getUserDb(userId);
|
||||||
if (syncPromise) return syncPromise;
|
if (syncPromise) return syncPromise;
|
||||||
|
|
||||||
syncPromise = (async () => {
|
syncPromise = (async () => {
|
||||||
|
|
@ -184,6 +186,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
},
|
},
|
||||||
async addEntry(entry: LedgerEntry) {
|
async addEntry(entry: LedgerEntry) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
userId: auth.user?.id,
|
userId: auth.user?.id,
|
||||||
|
|
@ -207,6 +210,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
},
|
},
|
||||||
async updateEntry(entry: LedgerEntry) {
|
async updateEntry(entry: LedgerEntry) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
|
||||||
const existing = await db.entries.get(entry.id);
|
const existing = await db.entries.get(entry.id);
|
||||||
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
||||||
|
|
||||||
|
|
@ -256,6 +260,9 @@ export const useEntryStore = defineStore("entries", {
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
async resolveLocalConversion(entryId: string) {
|
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);
|
const entry = await db.entries.get(entryId);
|
||||||
if (!entry || entry.deletedAt || entry.currency === "CNY") return;
|
if (!entry || entry.deletedAt || entry.currency === "CNY") return;
|
||||||
const cacheId = `${entry.currency}:${entry.exchangeRateDate}`;
|
const cacheId = `${entry.currency}:${entry.exchangeRateDate}`;
|
||||||
|
|
@ -322,6 +329,8 @@ export const useEntryStore = defineStore("entries", {
|
||||||
},
|
},
|
||||||
async deleteEntry(entryId: string) {
|
async deleteEntry(entryId: string) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
if (!auth.user) return;
|
||||||
|
const db = await getUserDb(auth.user.id);
|
||||||
const existing = await db.entries.get(entryId);
|
const existing = await db.entries.get(entryId);
|
||||||
if (!existing || existing.deletedAt) return;
|
if (!existing || existing.deletedAt) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { apiRequest } from "../data/api";
|
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 { currentLedgerPreferenceId, ledgerAmountDisplayPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers";
|
||||||
import type { LedgerAmountDisplayMode } from "@cents/domain";
|
import type { LedgerAmountDisplayMode } from "@cents/domain";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
|
|
@ -39,6 +39,7 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
ledgerLoadPromise = (async () => {
|
ledgerLoadPromise = (async () => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (!auth.user) return;
|
if (!auth.user) return;
|
||||||
|
const db = await getUserDb(auth.user.id);
|
||||||
const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers");
|
const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers");
|
||||||
const displayPreferences = await db.userPreferences
|
const displayPreferences = await db.userPreferences
|
||||||
.where("userId")
|
.where("userId")
|
||||||
|
|
@ -91,6 +92,7 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
async persistCurrentLedger() {
|
async persistCurrentLedger() {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (!this.currentLedgerId || !auth.user) return;
|
if (!this.currentLedgerId || !auth.user) return;
|
||||||
|
const db = await getUserDb(auth.user.id);
|
||||||
const preference: UserPreference = {
|
const preference: UserPreference = {
|
||||||
id: currentLedgerPreferenceId(auth.user.id),
|
id: currentLedgerPreferenceId(auth.user.id),
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
|
|
@ -125,6 +127,7 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
if (amountDisplay) {
|
if (amountDisplay) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
|
const db = await getUserDb(auth.user.id);
|
||||||
const preference: UserPreference = {
|
const preference: UserPreference = {
|
||||||
id: ledgerAmountDisplayPreferenceId(auth.user.id, input.id),
|
id: ledgerAmountDisplayPreferenceId(auth.user.id, input.id),
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue