(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

25
packages/cache/package.json vendored Normal file
View file

@ -0,0 +1,25 @@
{
"name": "@tensamin/cache",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./helpers": "./src/helpers.ts",
"./schemas": "./src/schemas.ts",
"./sync": "./src/sync.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0",
"zod": "^4.3.6"
}
}

50
packages/cache/src/helpers.test.ts vendored Normal file
View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import type { CachedMessage, ConversationWindow } from "./schemas";
const message = (SendTime: number): CachedMessage => ({
SenderId: 1,
SendTime,
Content: "Y2lwaGVydGV4dA==",
MessageState: "received",
});
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
UserId,
LastMessageAt,
Messages: [],
});
describe("conversation cache helpers", () => {
it("selects the five most recent windows", () => {
const selected = selectConversationWindows(
[
window(1, 1),
window(2, 6),
window(3, 3),
window(4, 4),
window(5, 5),
window(6, 2),
],
5,
);
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
});
it("replaces only the matching conversation", () => {
expect(
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
).toEqual([window(1, 9), window(2, 2)]);
});
it("retains the newest messages in chronological order", () => {
expect(
trimMessages([message(2), message(3), message(1)], 2).map(
(item) => item.SendTime,
),
).toEqual([2, 3]);
});
});

30
packages/cache/src/helpers.ts vendored Normal file
View file

@ -0,0 +1,30 @@
import type { CachedMessage, ConversationWindow } from "./schemas";
export function trimMessages(
messages: readonly CachedMessage[],
limit: number,
): CachedMessage[] {
return [...messages]
.sort((a, b) => b.SendTime - a.SendTime)
.slice(0, limit)
.sort((a, b) => a.SendTime - b.SendTime);
}
export function replaceConversation(
windows: readonly ConversationWindow[],
replacement: ConversationWindow,
): ConversationWindow[] {
return [
replacement,
...windows.filter((item) => item.UserId !== replacement.UserId),
];
}
export function selectConversationWindows(
windows: readonly ConversationWindow[],
limit: number,
): ConversationWindow[] {
return [...windows]
.sort((a, b) => b.LastMessageAt - a.LastMessageAt || a.UserId - b.UserId)
.slice(0, limit);
}

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

27
packages/cache/src/schemas.ts vendored Normal file
View file

@ -0,0 +1,27 @@
import { mtp } from "@tensamin/shared/data";
import { z } from "zod";
export const accountIdSchema = z.string().min(1);
export const contactSchema = z.object({
LastMessageAt: z.number(),
UserId: z.number(),
LastMessage: z
.object({ Content: z.base64(), SenderId: z.number() })
.optional(),
Messages: z.array(mtp.MessageGet.response),
});
export const contactsSchema = z.array(contactSchema);
export const userProfileSchema = mtp.GetUserData.response;
// Content remains the protocol base64 ciphertext. This package never decrypts messages.
export const cachedMessageSchema = mtp.MessageGet.response;
export const conversationWindowSchema = z.object({
UserId: z.number(),
LastMessageAt: z.number(),
Messages: z.array(cachedMessageSchema),
});
export type Contact = z.infer<typeof contactSchema>;
export type UserProfile = z.infer<typeof userProfileSchema>;
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;

271
packages/cache/src/sync.tsx vendored Normal file
View file

@ -0,0 +1,271 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
createCache,
type CachedMessage,
type UserProfile,
} from "@tensamin/cache";
import { useMTP, type MTPExchange, type ProtocolMessage } from "@tensamin/mtp";
import { useStorage } from "@tensamin/storage/context";
import { secureValueCodec } from "@tensamin/storage/secure";
function isError(message: ProtocolMessage) {
return message.type.startsWith("Error");
}
export default function CacheSync() {
const { addInterceptor, contextReady, freshContacts, subscribePush } =
useMTP();
const { load } = useStorage();
const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve());
const reactionPartnersRef = useRef(new Map<number, number>());
useEffect(() => {
void load("user_id").then(setAccountId);
}, [load]);
const enqueue = useCallback((operation: () => Promise<void>) => {
const next = queueRef.current.then(operation);
queueRef.current = next.catch(() => undefined);
return next;
}, []);
const secureCache = useCallback(
() =>
createCache(String(accountId), {
codec: secureValueCodec,
}),
[accountId],
);
const replaceMessage = useCallback(
async (
partnerId: number,
sendTime: number,
edit: Partial<CachedMessage>,
) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
if (!window) return;
await cache.conversations.replace({
...window,
Messages: window.Messages.map((message) =>
message.SendTime === sendTime ? { ...message, ...edit } : message,
),
});
},
[secureCache],
);
const insertMessage = useCallback(
async (partnerId: number, message: CachedMessage) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
await cache.conversations.replace({
UserId: partnerId,
LastMessageAt: Math.max(window?.LastMessageAt ?? 0, message.SendTime),
Messages: [
...(window?.Messages ?? []).filter(
(cached) => cached.SendTime !== message.SendTime,
),
message,
],
});
},
[secureCache],
);
const removeMessage = useCallback(
async (partnerId: number, sendTime: number) => {
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
if (!window) return;
await cache.conversations.replace({
...window,
Messages: window.Messages.filter(
(message) => message.SendTime !== sendTime,
),
});
},
[secureCache],
);
useEffect(() => {
if (!accountId || !contextReady) return;
void enqueue(async () => {
const cache = secureCache();
await cache.contacts.replace(freshContacts);
await cache.conversations.replaceSelected(
freshContacts.map((contact) => ({
UserId: contact.UserId,
LastMessageAt: contact.LastMessageAt,
Messages: contact.Messages,
})),
);
});
}, [accountId, contextReady, enqueue, freshContacts, secureCache]);
const synchronizeExchange = useCallback(
async ({ type, data, response }: MTPExchange) => {
if (!accountId || isError(response)) return;
const request = (data ?? {}) as Record<string, unknown>;
const result = response.data as Record<string, unknown>;
if (type === "GetUserData") {
await createCache(String(accountId)).profiles.put(
result as unknown as UserProfile,
);
return;
}
if (type === "MessagesGet" && Number(request.Offset) === 0) {
const partnerId = Number(request.UserId);
const messages = result.Messages as CachedMessage[];
const cache = secureCache();
const previous = await cache.conversations.get(partnerId);
await cache.conversations.replace({
UserId: partnerId,
LastMessageAt: Math.max(
previous?.LastMessageAt ?? 0,
...messages.map((message) => message.SendTime),
),
// This replacement is authoritative: absent server messages are deleted.
Messages: messages,
});
return;
}
if (type === "MessageSend") {
const partnerId = Number(request.ReceiverId);
await insertMessage(partnerId, {
Content: String(request.Content),
Files: request.Files as CachedMessage["Files"],
MessageState: "sent",
SenderId: accountId,
SendTime: Number(request.SendTime),
});
return;
}
if (type === "MessageEdit") {
await replaceMessage(
Number(request.ChatPartnerId),
Number(request.SendTime),
{ Content: String(request.Content), Edited: true },
);
return;
}
if (type === "MessageDelete") {
await removeMessage(
Number(request.ChatPartnerId),
Number(request.SendTime),
);
return;
}
if (type === "MessageReactionAdd" || type === "MessageReactionRemove") {
const partnerId = Number(request.ChatPartnerId);
const sendTime = Number(request.SendTime);
const reaction = String(request.Reaction);
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
const message = window?.Messages.find(
(candidate) => candidate.SendTime === sendTime,
);
if (!message) return;
const reactions = (message.Reactions ?? []).filter(
(candidate) =>
candidate.SenderId !== accountId || candidate.Reaction !== reaction,
);
if (type === "MessageReactionAdd") {
reactions.push({ SenderId: accountId, Reaction: reaction });
}
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
return;
}
if (type === "MessageState") {
await replaceMessage(
Number(result.ChatPartnerId),
Number(result.SendTime),
{
MessageState: result.MessageState as CachedMessage["MessageState"],
},
);
return;
}
if (type === "MessageGet") {
const message = result as unknown as CachedMessage;
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
reactionPartnersRef.current.delete(message.SendTime);
if (mappedPartner) {
await insertMessage(mappedPartner, message);
return;
}
const windows = await secureCache().conversations.list();
const window = windows.find((candidate) =>
candidate.Messages.some(
(cached) => cached.SendTime === message.SendTime,
),
);
if (window) await insertMessage(window.UserId, message);
}
},
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
);
useEffect(() => {
if (!accountId) return;
return addInterceptor((exchange) =>
enqueue(() => synchronizeExchange(exchange)),
);
}, [accountId, addInterceptor, enqueue, synchronizeExchange]);
const synchronizePush = useCallback(
async (message: ProtocolMessage) => {
if (!accountId || isError(message)) return;
const data = message.data as Record<string, unknown>;
if (message.type === "MessageLive") {
await insertMessage(
Number(data.SenderId),
data.Message as CachedMessage,
);
return;
}
if (message.type === "MessageEditLive") {
await replaceMessage(
Number(data.ChatPartnerId),
Number(data.SendTime),
{ Content: String(data.Content), Edited: true },
);
return;
}
if (message.type === "MessageState") {
await replaceMessage(
Number(data.ChatPartnerId),
Number(data.SendTime),
{ MessageState: data.MessageState as CachedMessage["MessageState"] },
);
return;
}
if (message.type === "MessageReactionLive") {
reactionPartnersRef.current.set(
Number(data.SendTime),
Number(data.ChatPartnerId),
);
}
},
[accountId, insertMessage, replaceMessage],
);
useEffect(() => {
if (!accountId || !contextReady) return;
return subscribePush((message) => {
void enqueue(() => synchronizePush(message));
});
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
return null;
}

13
packages/cache/tsconfig.json vendored Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}