Huginn/dist/cult-cache.js
2026-05-06 11:23:12 +01:00

536 lines
21 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CultCache = exports.CultCacheBuilder = void 0;
const msgpack_1 = require("@msgpack/msgpack");
class CultCacheBuilder {
#cache = new CultCache();
withDocumentType(definition) {
this.#cache.registerDocumentType(definition);
return this;
}
withRegistry(registry) {
this.#cache.registerRegistry(registry);
return this;
}
withBackingStore(store, ...types) {
this.#cache.addBackingStore(store, ...types);
return this;
}
withGenericStore(store) {
this.#cache.addGenericBackingStore(store);
return this;
}
build() {
return this.#cache;
}
}
exports.CultCacheBuilder = CultCacheBuilder;
class CultCache {
static GLOBAL_KEY = "__global__";
#definitions = new Map();
#entries = new Map();
#typeEntryIds = new Map();
#nameToKeyMaps = new Map();
#indexToKeyMaps = new Map();
#globalKeys = new Map();
#stores = [];
static builder() {
return new CultCacheBuilder();
}
registerDocumentType(definition) {
const existing = this.#definitions.get(definition.type);
if (existing && existing.definition !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
const registered = existing ?? {
definition,
global: definition.global === true,
formatter: this.#createFormatter(definition),
nameAccessor: undefined,
indexAccessors: new Map(),
};
registered.nameAccessor = definition.name
? this.#compileAccessor(definition.type, "name", definition.name)
: undefined;
registered.indexAccessors.clear();
for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) {
registered.indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
}
this.#definitions.set(definition.type, registered);
this.#rebuildDefinitionLookups(registered);
return definition;
}
registerRegistry(registry) {
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
? registry
: registry.definitions;
for (const definition of definitions) {
this.registerDocumentType(definition);
}
return this;
}
registerNameLookup(definition, accessor) {
const registered = this.#requireDefinition(definition);
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
this.#rebuildNameLookup(registered);
return definition;
}
registerIndex(definition, indexName, accessor) {
if (!indexName || indexName.trim().length === 0) {
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
}
const registered = this.#requireDefinition(definition);
registered.indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
this.#rebuildIndexLookup(registered, indexName);
return definition;
}
addBackingStore(store, ...types) {
this.#stores.push({
store,
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
});
}
addGenericBackingStore(store) {
this.addBackingStore(store);
}
async pullAllBackingStores() {
this.#resetHydratedState();
for (const registration of this.#stores) {
const entries = await registration.store.pullAll();
for (const entry of entries) {
const registered = this.#definitions.get(entry.type);
if (!registered) {
throw new Error(`No schema is registered for persisted document type "${entry.type}".`);
}
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
this.#applyHydratedEntry(registered, {
...entry,
payload,
}, value, "pull");
}
}
}
get(definition, key) {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? entry.value : undefined;
}
getRequired(definition, key) {
const value = this.get(definition, key);
if (value === undefined) {
throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`);
}
return value;
}
getEnvelope(definition, key) {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? this.#toEnvelope(entry) : undefined;
}
getRequiredEnvelope(definition, key) {
const entry = this.getEnvelope(definition, key);
if (!entry) {
throw new Error(`CultCache has no "${definition.type}" envelope at key "${key}".`);
}
return entry;
}
getGlobal(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.get(definition, globalKey) : undefined;
}
getRequiredGlobal(definition) {
const value = this.getGlobal(definition);
if (value === undefined) {
throw new Error(`CultCache has no global "${definition.type}" document.`);
}
return value;
}
getGlobalEnvelope(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.getEnvelope(definition, globalKey) : undefined;
}
getAll(definition) {
this.#requireDefinition(definition);
const values = [];
const entryIds = this.#typeEntryIds.get(definition.type);
if (!entryIds) {
return values;
}
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
values.push(entry.value);
}
}
return values;
}
getKeyByName(definition, name) {
const registered = this.#requireDefinition(definition);
if (!registered.nameAccessor) {
return undefined;
}
return this.#nameToKeyMaps.get(definition.type)?.get(name);
}
getIdByName(definition, name) {
return this.getKeyByName(definition, name);
}
getByName(definition, name) {
const key = this.getKeyByName(definition, name);
return key ? this.get(definition, key) : undefined;
}
getKeyByIndex(definition, indexName, value) {
this.#requireDefinition(definition);
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
}
getIdByIndex(definition, indexName, value) {
return this.getKeyByIndex(definition, indexName, value);
}
getByIndex(definition, indexName, value) {
const key = this.getKeyByIndex(definition, indexName, value);
return key ? this.get(definition, key) : undefined;
}
async put(definition, key, value) {
const registered = this.#requireDefinition(definition);
const payload = registered.formatter.encode(value);
const parsed = registered.formatter.decode(payload);
const entry = {
key,
type: definition.type,
payload: this.#cloneBytes(payload),
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}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putEnvelope(definition, envelope) {
const registered = this.#requireDefinition(definition);
if (envelope.type !== definition.type) {
throw new Error(`CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`);
}
if (!envelope.key || envelope.key.trim().length === 0) {
throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`);
}
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`);
}
const payload = this.#cloneBytes(envelope.payload);
const parsed = registered.formatter.decode(payload);
const entry = {
key: envelope.key,
type: envelope.type,
payload,
storedAt: envelope.storedAt,
};
const route = this.#resolveRoute(definition.type);
if (!route.primary) {
throw new Error(`No backing store is registered for document type "${definition.type}".`);
}
if (registered.global) {
const existingGlobalKey = this.#globalKeys.get(definition.type);
if (existingGlobalKey && existingGlobalKey !== entry.key) {
await this.delete(definition, existingGlobalKey);
}
}
await route.primary.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async putGlobal(definition, value) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
return this.put(definition, CultCache.GLOBAL_KEY, value);
}
async update(definition, key, updater) {
const current = this.get(definition, key);
return this.put(definition, key, updater(current));
}
async updateGlobal(definition, updater) {
const current = this.getGlobal(definition);
return this.putGlobal(definition, updater(current));
}
async delete(definition, key) {
const registered = 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}".`);
}
const envelope = this.#toEnvelope(entry);
await route.primary.delete(envelope);
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope)));
this.#removeHydratedEntry(registered, entry);
return true;
}
async deleteGlobal(definition) {
const registered = this.#requireDefinition(definition);
if (!registered.global) {
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
}
const globalKey = this.#globalKeys.get(definition.type);
return globalKey ? this.delete(definition, globalKey) : false;
}
snapshot() {
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
}
#createFormatter(definition) {
const userFormatter = definition.formatter ?? {
encode: (value) => (0, msgpack_1.encode)(value),
decode: (payload) => (0, msgpack_1.decode)(payload),
};
return {
encode: (value) => {
const parsed = definition.schema.parse(value);
return this.#cloneBytes(userFormatter.encode(parsed));
},
decode: (payload) => {
const decoded = userFormatter.decode(this.#cloneBytes(payload));
return definition.schema.parse(decoded);
},
};
}
#enumerateDefinitionIndexes(definition) {
if (!definition.indexes) {
return [];
}
if (Array.isArray(definition.indexes)) {
return definition.indexes.map((index) => [index.name, index.accessor]);
}
return Object.entries(definition.indexes);
}
#compileAccessor(type, label, accessor) {
if (typeof accessor === "function") {
return (value) => this.#normalizeIndexValue(type, label, accessor(value));
}
return (value) => {
if (value === null || typeof value !== "object") {
return undefined;
}
const fieldValue = value[accessor];
return this.#normalizeIndexValue(type, `${label} field "${accessor}"`, fieldValue);
};
}
#normalizeIndexValue(type, label, value) {
if (value === undefined || value === null) {
return undefined;
}
switch (typeof value) {
case "string":
return value;
case "number":
case "boolean":
case "bigint":
return String(value);
default:
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
}
}
#requireDefinition(definition) {
const registered = this.#definitions.get(definition.type);
if (!registered || registered.definition !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return registered;
}
#resolveRoute(type) {
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),
};
}
#applyHydratedEntry(registered, entry, value, source) {
const entryId = this.#entryId(entry.type, entry.key);
const existing = this.#entries.get(entryId);
if (existing) {
this.#removeHydratedEntry(registered, existing);
}
if (registered.global) {
const currentGlobalKey = this.#globalKeys.get(entry.type);
if (currentGlobalKey && currentGlobalKey !== entry.key) {
throw new Error(source === "pull"
? `CultCache global document type "${entry.type}" has multiple persisted entries.`
: `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`);
}
}
const hydrated = {
...entry,
payload: this.#cloneBytes(entry.payload),
value,
};
this.#entries.set(entryId, hydrated);
this.#getOrCreateTypeEntrySet(entry.type).add(entryId);
if (registered.global) {
this.#globalKeys.set(entry.type, entry.key);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(value);
if (name !== undefined) {
this.#getOrCreateNameMap(entry.type).set(name, entry.key);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const indexValue = accessor(value);
if (indexValue !== undefined) {
this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key);
}
}
}
#removeHydratedEntry(registered, entry) {
const entryId = this.#entryId(entry.type, entry.key);
this.#entries.delete(entryId);
const typeEntryIds = this.#typeEntryIds.get(entry.type);
if (typeEntryIds) {
typeEntryIds.delete(entryId);
if (typeEntryIds.size === 0) {
this.#typeEntryIds.delete(entry.type);
}
}
if (registered.global && this.#globalKeys.get(entry.type) === entry.key) {
this.#globalKeys.delete(entry.type);
}
if (registered.nameAccessor) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
this.#nameToKeyMaps.get(entry.type)?.delete(name);
}
}
for (const [indexName, accessor] of registered.indexAccessors) {
const value = accessor(entry.value);
if (value !== undefined) {
this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value);
}
}
}
#rebuildDefinitionLookups(registered) {
this.#rebuildNameLookup(registered);
for (const indexName of registered.indexAccessors.keys()) {
this.#rebuildIndexLookup(registered, indexName);
}
}
#rebuildNameLookup(registered) {
this.#nameToKeyMaps.delete(registered.definition.type);
if (!registered.nameAccessor) {
return;
}
const map = this.#getOrCreateNameMap(registered.definition.type);
for (const entry of this.#entriesForType(registered.definition.type)) {
const name = registered.nameAccessor(entry.value);
if (name !== undefined) {
map.set(name, entry.key);
}
}
}
#rebuildIndexLookup(registered, indexName) {
const lookupId = this.#indexId(registered.definition.type, indexName);
this.#indexToKeyMaps.delete(lookupId);
const accessor = registered.indexAccessors.get(indexName);
if (!accessor) {
return;
}
const map = this.#getOrCreateIndexMap(registered.definition.type, indexName);
for (const entry of this.#entriesForType(registered.definition.type)) {
const value = accessor(entry.value);
if (value !== undefined) {
map.set(value, entry.key);
}
}
}
#entriesForType(type) {
const entryIds = this.#typeEntryIds.get(type);
if (!entryIds) {
return [];
}
const entries = [];
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
entries.push(entry);
}
}
return entries;
}
#getOrCreateTypeEntrySet(type) {
let set = this.#typeEntryIds.get(type);
if (!set) {
set = new Set();
this.#typeEntryIds.set(type, set);
}
return set;
}
#getOrCreateNameMap(type) {
let map = this.#nameToKeyMaps.get(type);
if (!map) {
map = new Map();
this.#nameToKeyMaps.set(type, map);
}
return map;
}
#getOrCreateIndexMap(type, indexName) {
const lookupId = this.#indexId(type, indexName);
let map = this.#indexToKeyMaps.get(lookupId);
if (!map) {
map = new Map();
this.#indexToKeyMaps.set(lookupId, map);
}
return map;
}
#toEnvelope(entry) {
return {
key: entry.key,
type: entry.type,
payload: this.#cloneBytes(entry.payload),
storedAt: entry.storedAt,
};
}
#resetHydratedState() {
this.#entries.clear();
this.#typeEntryIds.clear();
this.#nameToKeyMaps.clear();
this.#indexToKeyMaps.clear();
this.#globalKeys.clear();
}
#entryId(type, key) {
return `${type}::${key}`;
}
#indexId(type, indexName) {
return `${type}::${indexName}`;
}
#cloneBytes(payload) {
return new Uint8Array(payload);
}
}
exports.CultCache = CultCache;