(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
276
packages/cache/src/index.ts
vendored
Normal file
276
packages/cache/src/index.ts
vendored
Normal 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>;
|
||||
Loading…
Reference in a new issue