269 lines
8.1 KiB
TypeScript
269 lines
8.1 KiB
TypeScript
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,
|
|
chatDraftSchema,
|
|
contactsSchema,
|
|
conversationWindowSchema,
|
|
userProfileSchema,
|
|
type ChatDraft,
|
|
type Contact,
|
|
type ConversationWindow,
|
|
type UserProfile,
|
|
} from "./schemas";
|
|
|
|
export * from "./helpers";
|
|
export * from "./schemas";
|
|
|
|
type CacheStore = "contacts" | "profiles" | "conversations" | "drafts";
|
|
|
|
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)),
|
|
},
|
|
drafts: {
|
|
get: (userId: number) => read("drafts", String(userId), chatDraftSchema),
|
|
put: (userId: number, draft: ChatDraft) =>
|
|
write("drafts", String(userId), chatDraftSchema, draft),
|
|
delete: (userId: number) => remove("drafts", String(userId)),
|
|
},
|
|
clearAccount: async () => {
|
|
ensureOpen();
|
|
await Promise.all(
|
|
(
|
|
["contacts", "profiles", "conversations", "drafts"] 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>;
|