(feat): add cache package
All checks were successful
/ build-web (push) Successful in 7m26s
/ build-desktop (linux) (push) Successful in 11m29s
/ build-mobile (push) Successful in 19m15s
/ release (push) Successful in 3m3s

(feat): improve local storage security
(feat): move settings to dedicated settings package
This commit is contained in:
Alois 2026-07-11 22:08:01 +02:00
commit 790a1db788
54 changed files with 1984 additions and 947 deletions

276
packages/cache/src/index.ts vendored Normal file
View file

@ -0,0 +1,276 @@
import type { z } from "zod";
import { storageDefaults } from "@tensamin/shared/data";
import {
deleteDatabaseEntry,
getDatabaseEntry,
listDatabaseEntries,
setDatabaseEntry,
} from "@tensamin/shared/indexedDb";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import {
accountIdSchema,
contactsSchema,
conversationWindowSchema,
userProfileSchema,
type Contact,
type ConversationWindow,
type UserProfile,
} from "./schemas";
export * from "./helpers";
export * from "./schemas";
type CacheStore = "contacts" | "profiles" | "conversations";
export interface SecureValueCodec {
encode(value: unknown): unknown | Promise<unknown>;
decode(value: unknown): unknown | Promise<unknown>;
}
export interface CacheOptions {
codec?: SecureValueCodec;
contacts?: number;
messagesPerChat?: number;
}
export interface CacheLifecycle {
clearAccount(): Promise<void>;
close(): Promise<void>;
}
const identityCodec: SecureValueCodec = {
encode: (value) => value,
decode: (value) => value,
};
export function createCache(accountId: string, options: CacheOptions = {}) {
const account = accountIdSchema.parse(accountId);
const codec = options.codec ?? identityCodec;
const getLimits = async () => {
const [storedContacts, storedMessagesPerChat] = await Promise.all([
getDatabaseEntry("storage", "cache_contacts"),
getDatabaseEntry("storage", "cache_messages_per_chat"),
]);
return {
contacts: Math.max(
0,
Math.floor(
options.contacts ??
(typeof storedContacts === "number"
? storedContacts
: storageDefaults.cache_contacts),
),
),
messagesPerChat: Math.max(
0,
Math.floor(
options.messagesPerChat ??
(typeof storedMessagesPerChat === "number"
? storedMessagesPerChat
: storageDefaults.cache_messages_per_chat),
),
),
};
};
const prefix = `${account}:`;
const storedPrefix = (store: CacheStore) => `${store}:${prefix}`;
const storedKey = (store: CacheStore, key: string) =>
`${storedPrefix(store)}${key}`;
const entries = async (store: CacheStore) => {
const storePrefix = storedPrefix(store);
return (await listDatabaseEntries("cache", storePrefix)).map(
([key, value]) => [key.slice(storePrefix.length), value] as const,
);
};
let closed = false;
const ensureOpen = () => {
if (closed) throw new Error("Cache is closed");
};
const read = async <T>(
store: CacheStore,
key: string,
schema: z.ZodType<T>,
) => {
ensureOpen();
const value = await getDatabaseEntry("cache", storedKey(store, key));
return value === undefined
? undefined
: schema.parse(await codec.decode(value));
};
const write = async <T>(
store: CacheStore,
key: string,
schema: z.ZodType<T>,
value: T,
) => {
ensureOpen();
await setDatabaseEntry(
"cache",
storedKey(store, key),
await codec.encode(schema.parse(value)),
);
};
const remove = async (store: CacheStore, key: string) => {
ensureOpen();
await deleteDatabaseEntry("cache", storedKey(store, key));
};
const listConversations = async () => {
ensureOpen();
const storedEntries = await entries("conversations");
const windows = await Promise.all(
storedEntries.map(async ([, value]) =>
conversationWindowSchema.parse(await codec.decode(value)),
),
);
const { contacts } = await getLimits();
return selectConversationWindows(windows, contacts);
};
return {
contacts: {
get: () => read("contacts", "authoritative", contactsSchema),
replace: (contacts: Contact[]) =>
write("contacts", "authoritative", contactsSchema, contacts),
clear: () => remove("contacts", "authoritative"),
},
profiles: {
get: (userId: number) =>
read("profiles", String(userId), userProfileSchema),
put: (profile: UserProfile) =>
write("profiles", String(profile.UserId), userProfileSchema, profile),
delete: (userId: number) => remove("profiles", String(userId)),
},
conversations: {
list: listConversations,
get: (userId: number) =>
read("conversations", String(userId), conversationWindowSchema),
replace: async (window: ConversationWindow) => {
const { contacts, messagesPerChat } = await getLimits();
const candidate = conversationWindowSchema.parse({
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
});
const selected = selectConversationWindows(
replaceConversation(await listConversations(), candidate),
contacts,
);
await Promise.all(
selected.map((item) =>
write(
"conversations",
String(item.UserId),
conversationWindowSchema,
item,
),
),
);
const retained = new Set(selected.map((item) => item.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) => {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}),
);
},
replaceSelected: async (windows: ConversationWindow[]) => {
const { contacts, messagesPerChat } = await getLimits();
const selected = selectConversationWindows(
windows.map((window) => ({
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
})),
contacts,
);
await Promise.all(
selected.map((item) =>
write(
"conversations",
String(item.UserId),
conversationWindowSchema,
item,
),
),
);
const retained = new Set(selected.map((item) => item.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) => {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}),
);
},
prune: async () => {
const { messagesPerChat } = await getLimits();
const windows = await listConversations();
await Promise.all(
windows.map((window) =>
write(
"conversations",
String(window.UserId),
conversationWindowSchema,
{
...window,
Messages: trimMessages(window.Messages, messagesPerChat),
},
),
),
);
const retained = new Set(windows.map((window) => window.UserId));
const storedEntries = await entries("conversations");
await Promise.all(
storedEntries.flatMap(([key]) =>
retained.has(Number(key))
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
],
),
);
},
delete: (userId: number) => remove("conversations", String(userId)),
},
clearAccount: async () => {
ensureOpen();
await Promise.all(
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
async (store) => {
const storedEntries = await entries(store);
await Promise.all(
storedEntries.map(([key]) =>
deleteDatabaseEntry("cache", storedKey(store, key)),
),
);
},
),
);
},
close: async () => {
closed = true;
},
};
}
export type Cache = ReturnType<typeof createCache>;