Huginn/src/cult-cache.ts
2026-05-06 03:11:02 +01:00

186 lines
5.8 KiB
TypeScript

import type {
CacheBackingStore,
CultCacheDocumentDefinition,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheStoreRegistration,
} from "./types";
type StoreRoute = {
primary?: CacheBackingStore;
mirrors: CacheBackingStore[];
};
export class CultCache {
readonly #definitions = new Map<string, CultCacheDocumentDefinition>();
readonly #entries = new Map<string, CultCacheEnvelope>();
readonly #stores: CultCacheStoreRegistration[] = [];
registerDocumentType<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
): TDefinition {
const existing = this.#definitions.get(definition.type);
if (existing && existing !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
this.#definitions.set(definition.type, definition);
return definition;
}
addBackingStore(store: CacheBackingStore, ...types: string[]): void {
this.#stores.push({
store,
types: [...new Set(types)],
});
}
async pullAllBackingStores(): Promise<void> {
this.#entries.clear();
for (const registration of this.#stores) {
const entries = await registration.store.pullAll();
for (const entry of entries) {
const definition = this.#definitions.get(entry.type);
if (!definition) {
throw new Error(`No schema is registered for persisted document type "${entry.type}".`);
}
const parsed = definition.schema.parse(entry.payload);
this.#entries.set(this.#entryId(entry.type, entry.key), {
...entry,
payload: parsed,
});
}
}
}
get<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheDocumentValue<TDefinition> | undefined {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? (entry.payload as CultCacheDocumentValue<TDefinition>) : undefined;
}
getRequired<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheDocumentValue<TDefinition> {
const value = this.get(definition, key);
if (value === undefined) {
throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`);
}
return value;
}
getAll<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition>[] {
this.#requireDefinition(definition);
const values: CultCacheDocumentValue<TDefinition>[] = [];
for (const entry of this.#entries.values()) {
if (entry.type === definition.type) {
values.push(entry.payload as CultCacheDocumentValue<TDefinition>);
}
}
return values;
}
async put<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
this.#requireDefinition(definition);
const parsed = definition.schema.parse(value) as CultCacheDocumentValue<TDefinition>;
const entry: CultCacheEnvelope = {
key,
type: definition.type,
payload: parsed,
storedAt: new Date().toISOString(),
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#entries.set(this.#entryId(definition.type, key), entry);
return parsed;
}
async update<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
updater: (
current: CultCacheDocumentValue<TDefinition> | undefined,
) => CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const current = this.get(definition, key);
return this.put(definition, key, updater(current));
}
async delete<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): Promise<boolean> {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
if (!entry) {
return false;
}
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
await route.primary.delete(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(entry)));
this.#entries.delete(this.#entryId(definition.type, key));
return true;
}
snapshot(): CultCacheEnvelope[] {
return [...this.#entries.values()].map((entry) => structuredClone(entry));
}
#requireDefinition<TDefinition extends CultCacheDocumentDefinition>(
definition: TDefinition,
): TDefinition {
const registered = this.#definitions.get(definition.type);
if (!registered || registered !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return definition;
}
#resolveRoute(type: string): StoreRoute {
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type));
if (typeSpecific.length > 0) {
return {
primary: typeSpecific[0]?.store,
mirrors: typeSpecific.slice(1).map((registration) => registration.store),
};
}
const generic = this.#stores.filter((registration) => registration.types.length === 0);
return {
primary: generic[0]?.store,
mirrors: generic.slice(1).map((registration) => registration.store),
};
}
#entryId(type: string, key: string): string {
return `${type}::${key}`;
}
}