Expand CultCacheTS toward full CultCache ergonomics

This commit is contained in:
Meta Vens 2026-05-06 04:41:11 +01:00
commit be0a96db7b
23 changed files with 1488 additions and 119 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
node_modules/
dist-test/

189
README.md
View file

@ -1,28 +1,30 @@
# CultCacheTS
`CultCacheTS` is the TypeScript port of the useful part of GameCult's `CultCache` idea: consumer code talks to typed documents, while a polymorphism-aware persistence layer deals with schemas, routing, and MessagePack.
`CultCacheTS` is the TypeScript port of the useful part of GameCult's `CultCache` idea: consumer code talks to typed domain documents, while a closed-world persistence layer deals with schemas, routing, name lookups, indexes, globals, and raw MessagePack bytes.
This is not trying to cosplay as an ORM. It is for cases where you want:
This is not an ORM in a fake mustache. It is for cases where you want:
- one cache API for heterogeneous document types
- runtime schema validation on load and write
- MessagePack-backed persistence
- backing-store routing instead of every script pawing raw files directly
- one cache API for heterogeneous domain documents
- typed reads and writes instead of every script massaging anonymous blobs
- lookup by type, logical key, name, and indexed property
- singleton-style global documents
- backing-store routing instead of every caller pawing raw files directly
- a persistence boundary that refuses unregistered polymorphic junk
## What It Does
## Current Shape
- registers document types with Zod schemas
- stores heterogeneous documents behind a `type` discriminator
- persists them through pluggable backing stores
- ships with a hardened single-file MessagePack backing store
- document types are registered explicitly
- each persisted envelope stores:
- `type`
- `key`
- raw MessagePack `payload` bytes
- `storedAt`
- payload bytes only decode through the registered document definition for that `type`
- name and index maps live in the cache, not in the backing store
- global documents are treated as a singleton per type
- backing stores are adapters, not the public data model
## What It Does Not Pretend To Do
- cross-process distributed consensus
- magical schema migrations
- every C# `CultCache` feature on day one
If multiple processes write the same backing file, use an external lock or a higher-order coordinator. The single-file store is atomic and process-local queue-safe, not a tiny database pretending it invented physics.
That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.
## Example
@ -31,35 +33,166 @@ import { z } from "zod";
import {
CultCache,
SingleFileMessagePackBackingStore,
defineDocumentRegistry,
defineDocumentType,
} from "cultcache-ts";
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({
name: z.string(),
category: z.string(),
value: z.number().int(),
}),
name: "name",
indexes: {
category: "category",
},
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
retries: z.number().int().nonnegative(),
}),
global: true,
});
const cache = new CultCache();
cache.registerDocumentType(settingsDocument);
cache.addBackingStore(new SingleFileMessagePackBackingStore("cache.msgpack"));
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore("cache.msgpack"))
.build();
await cache.pullAllBackingStores();
await cache.put(settingsDocument, "app", {
await cache.put(itemDocument, "item:potion", {
name: "Potion",
category: "Consumable",
value: 50,
});
await cache.putGlobal(settingsDocument, {
theme: "ash",
retries: 3,
});
const settings = cache.getRequired(settingsDocument, "app");
const potion = cache.getByName(itemDocument, "Potion");
const consumable = cache.getByIndex(itemDocument, "category", "Consumable");
const settings = cache.getRequiredGlobal(settingsDocument);
```
## Current Scope
## Public Surface
- `CultCache`
- `SingleFileMessagePackBackingStore`
- typed document definitions via `defineDocumentType`
- `defineDocumentType(...)`
- `defineDocumentRegistry(...)`
- `CultCache.builder()`
- `registerDocumentType(...)`
- `registerRegistry(...)`
- `registerNameLookup(...)`
- `registerIndex(...)`
- `addBackingStore(...)`
- `addGenericBackingStore(...)`
- `pullAllBackingStores()`
- `get(...)`
- `getRequired(...)`
- `getAll(...)`
- `getKeyByName(...)`
- `getByName(...)`
- `getKeyByIndex(...)`
- `getByIndex(...)`
- `getGlobal(...)`
- `getRequiredGlobal(...)`
- `put(...)`
- `putGlobal(...)`
- `update(...)`
- `updateGlobal(...)`
- `delete(...)`
- `deleteGlobal(...)`
- `snapshot()`
That is enough for consumers to stop writing their own sad little JSON-file bureaucracies and get back to the interesting part.
## Name, Index, and Global Semantics
`name` and `indexes` can be declared on the document definition:
```ts
const playerDocument = defineDocumentType({
type: "player",
schema: playerSchema,
name: "displayName",
indexes: {
faction: "faction",
email: "email",
},
});
```
You can also register them later if you feel like changing the machine after boot:
```ts
cache.registerNameLookup(playerDocument, "displayName");
cache.registerIndex(playerDocument, "faction", "faction");
```
Global documents are singleton-style per type:
```ts
await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 });
const settings = cache.getRequiredGlobal(settingsDocument);
```
If persisted state contains two globals for the same type, `pullAllBackingStores()` fails closed instead of pretending the contradiction is charming.
## Typed Formatters
Each document definition can provide a custom formatter:
```ts
const combatLogDocument = defineDocumentType({
type: "combat-log",
schema: combatLogSchema,
formatter: {
encode(value) {
return myFastBinaryEncoder(value);
},
decode(payload) {
return myFastBinaryDecoder(payload);
},
},
});
```
If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by default.
The important boundary is this:
- payload bytes are persisted as bytes
- decoding goes through the registered formatter for that document type
- the decoded value is still validated against the declared schema
So callers get typed values, and the persistence layer does not become an open polymorphic sewer.
## Security Model
`CultCacheTS` should never deserialize arbitrary persisted payloads into arbitrary runtime shapes.
It only accepts persisted envelopes whose `type` discriminator resolves to a registered document definition on the cache instance. Unknown types fail immediately. Known types decode through the registered formatter and schema for that type only.
That is the TypeScript version of the original CultCache trick:
- closed discriminator set
- explicit registration
- typed formatter path
- no runtime type-name roulette
## Persistence Model
`SingleFileMessagePackBackingStore` is the first concrete store.
- it writes atomic snapshots
- it queues local writes so one process does not step on its own shoelaces
- it persists raw MessagePack payload bytes inside MessagePack envelopes
It is a control-plane store, not a tiny god. If multiple processes hammer the same file, you still need a coordinator or an external lock instead of prayer.

45
dist/cult-cache.d.ts vendored
View file

@ -1,15 +1,42 @@
import type { CacheBackingStore, CultCacheDocumentDefinition, CultCacheDocumentValue, CultCacheEnvelope } from "./types";
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheDocumentAccessor, CultCacheDocumentRegistry, CultCacheDocumentValue, CultCacheEnvelope } from "./types";
type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition;
export declare class CultCacheBuilder {
#private;
withDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this;
withRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
withBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): this;
withGenericStore(store: CacheBackingStore): this;
build(): CultCache;
}
export declare class CultCache {
#private;
registerDocumentType<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition): TDefinition;
addBackingStore(store: CacheBackingStore, ...types: string[]): void;
static readonly GLOBAL_KEY = "__global__";
static builder(): CultCacheBuilder;
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): TDefinition;
registerRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
addBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): void;
addGenericBackingStore(store: CacheBackingStore): void;
pullAllBackingStores(): Promise<void>;
get<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition> | undefined;
getRequired<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition>;
getAll<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>[];
put<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
update<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
delete<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): Promise<boolean>;
get<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition> | undefined;
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition>;
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition> | undefined;
getRequiredGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>;
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>[];
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): CultCacheDocumentValue<TDefinition> | undefined;
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): CultCacheDocumentValue<TDefinition> | undefined;
put<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
update<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
delete<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): Promise<boolean>;
deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): Promise<boolean>;
snapshot(): CultCacheEnvelope[];
}
export {};
//# sourceMappingURL=cult-cache.d.ts.map

View file

@ -1 +1 @@
{"version":3,"file":"cult-cache.d.ts","sourceRoot":"","sources":["../src/cult-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,2BAA2B,EAC3B,sBAAsB,EACtB,iBAAiB,EAElB,MAAM,SAAS,CAAC;AAOjB,qBAAa,SAAS;;IAKpB,oBAAoB,CAAC,WAAW,SAAS,2BAA2B,EAClE,UAAU,EAAE,WAAW,GACtB,WAAW;IAUd,eAAe,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IAO7D,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB3C,GAAG,CAAC,WAAW,SAAS,2BAA2B,EACjD,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAMlD,WAAW,CAAC,WAAW,SAAS,2BAA2B,EACzD,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC;IAStC,MAAM,CAAC,WAAW,SAAS,2BAA2B,EACpD,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC,EAAE;IAalC,GAAG,CAAC,WAAW,SAAS,2BAA2B,EACvD,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,sBAAsB,CAAC,WAAW,CAAC,GACzC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IAqBzC,MAAM,CAAC,WAAW,SAAS,2BAA2B,EAC1D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CACP,OAAO,EAAE,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS,KACrD,sBAAsB,CAAC,WAAW,CAAC,GACvC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IAKzC,MAAM,CAAC,WAAW,SAAS,2BAA2B,EAC1D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC;IAkBnB,QAAQ,IAAI,iBAAiB,EAAE;CAkChC"}
{"version":3,"file":"cult-cache.d.ts","sourceRoot":"","sources":["../src/cult-cache.ts"],"names":[],"mappings":"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"}

408
dist/cult-cache.js vendored
View file

@ -1,45 +1,120 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CultCache = void 0;
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) {
if (existing && existing.definition !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
this.#definitions.set(definition.type, definition);
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)],
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
});
}
addGenericBackingStore(store) {
this.addBackingStore(store);
}
async pullAllBackingStores() {
this.#entries.clear();
this.#resetHydratedState();
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) {
const registered = this.#definitions.get(entry.type);
if (!registered) {
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), {
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
this.#applyHydratedEntry(registered, {
...entry,
payload: parsed,
});
payload,
}, value, "pull");
}
}
}
get(definition, key) {
this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
return entry ? entry.payload : undefined;
return entry ? entry.value : undefined;
}
getRequired(definition, key) {
const value = this.get(definition, key);
@ -48,40 +123,103 @@ class CultCache {
}
return value;
}
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;
}
getAll(definition) {
this.#requireDefinition(definition);
const values = [];
for (const entry of this.#entries.values()) {
if (entry.type === definition.type) {
values.push(entry.payload);
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;
}
async put(definition, key, value) {
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);
const parsed = definition.schema.parse(value);
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: parsed,
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.#entries.set(this.#entryId(definition.type, key), 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) {
this.#requireDefinition(definition);
const registered = this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
if (!entry) {
return false;
@ -90,20 +228,81 @@ class CultCache {
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));
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) => structuredClone(entry));
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) {
if (!registered || registered.definition !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return definition;
return registered;
}
#resolveRoute(type) {
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type));
@ -119,8 +318,165 @@ class CultCache {
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;

3
dist/document.d.ts vendored
View file

@ -1,4 +1,5 @@
import type { ZodType } from "zod";
import type { CultCacheDocumentDefinition } from "./types";
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentDefinition, CultCacheDocumentRegistry } from "./types";
export declare function defineDocumentType<TSchema extends ZodType>(definition: CultCacheDocumentDefinition<TSchema>): CultCacheDocumentDefinition<TSchema>;
export declare function defineDocumentRegistry<TDefinitions extends readonly AnyCultCacheDocumentDefinition[]>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions>;
//# sourceMappingURL=document.d.ts.map

View file

@ -1 +1 @@
{"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../src/document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAEnC,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,OAAO,EACxD,UAAU,EAAE,2BAA2B,CAAC,OAAO,CAAC,GAC/C,2BAA2B,CAAC,OAAO,CAAC,CAMtC"}
{"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../src/document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAEnC,OAAO,KAAK,EAAE,8BAA8B,EAAE,2BAA2B,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAC;AAEtH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,OAAO,EACxD,UAAU,EAAE,2BAA2B,CAAC,OAAO,CAAC,GAC/C,2BAA2B,CAAC,OAAO,CAAC,CA6BtC;AAED,wBAAgB,sBAAsB,CACpC,YAAY,SAAS,SAAS,8BAA8B,EAAE,EAC9D,GAAG,WAAW,EAAE,YAAY,GAAG,yBAAyB,CAAC,YAAY,CAAC,CAIvE"}

30
dist/document.js vendored
View file

@ -1,9 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.defineDocumentType = defineDocumentType;
exports.defineDocumentRegistry = defineDocumentRegistry;
function defineDocumentType(definition) {
if (!definition.type || definition.type.trim().length === 0) {
throw new Error("CultCache document types must declare a non-empty type.");
}
return definition;
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
}
if (Array.isArray(definition.indexes)) {
const seen = new Set();
for (const index of definition.indexes) {
if (!index.name || index.name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
if (seen.has(index.name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
}
seen.add(index.name);
}
}
else if (definition.indexes) {
for (const name of Object.keys(definition.indexes)) {
if (!name || name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
}
}
return Object.freeze({ ...definition });
}
function defineDocumentRegistry(...definitions) {
return Object.freeze({
definitions: Object.freeze([...definitions]),
});
}

6
dist/index.d.ts vendored
View file

@ -1,5 +1,5 @@
export { CultCache } from "./cult-cache";
export { defineDocumentType } from "./document";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { defineDocumentRegistry, defineDocumentType } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export type { CacheBackingStore, CultCacheDocumentDefinition, CultCacheDocumentValue, CultCacheEnvelope, CultCacheStoreRegistration, PushAllOptions, } from "./types";
export type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheDocumentAccessor, CultCacheDocumentDefinition, CultCacheDocumentFieldName, CultCacheDocumentFormatter, CultCacheDocumentIndexDefinition, CultCacheDocumentRegistry, CultCacheDocumentValue, CultCacheEnvelope, CultCacheIndexScalar, CultCacheStoreRegistration, PushAllOptions, } from "./types";
//# sourceMappingURL=index.d.ts.map

2
dist/index.d.ts.map vendored
View file

@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;AAC5F,YAAY,EACV,iBAAiB,EACjB,2BAA2B,EAC3B,sBAAsB,EACtB,iBAAiB,EACjB,0BAA0B,EAC1B,cAAc,GACf,MAAM,SAAS,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,iCAAiC,EAAE,MAAM,yCAAyC,CAAC;AAC5F,YAAY,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,gCAAgC,EAChC,yBAAyB,EACzB,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,0BAA0B,EAC1B,cAAc,GACf,MAAM,SAAS,CAAC"}

4
dist/index.js vendored
View file

@ -1,9 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.CultCache = void 0;
exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.defineDocumentRegistry = exports.CultCacheBuilder = exports.CultCache = void 0;
var cult_cache_1 = require("./cult-cache");
Object.defineProperty(exports, "CultCache", { enumerable: true, get: function () { return cult_cache_1.CultCache; } });
Object.defineProperty(exports, "CultCacheBuilder", { enumerable: true, get: function () { return cult_cache_1.CultCacheBuilder; } });
var document_1 = require("./document");
Object.defineProperty(exports, "defineDocumentRegistry", { enumerable: true, get: function () { return document_1.defineDocumentRegistry; } });
Object.defineProperty(exports, "defineDocumentType", { enumerable: true, get: function () { return document_1.defineDocumentType; } });
var single_file_messagepack_backing_store_1 = require("./single-file-messagepack-backing-store");
Object.defineProperty(exports, "SingleFileMessagePackBackingStore", { enumerable: true, get: function () { return single_file_messagepack_backing_store_1.SingleFileMessagePackBackingStore; } });

View file

@ -8,7 +8,7 @@ const zod_1 = require("zod");
const envelopeSchema = zod_1.z.object({
key: zod_1.z.string().min(1),
type: zod_1.z.string().min(1),
payload: zod_1.z.unknown(),
payload: zod_1.z.instanceof(Uint8Array),
storedAt: zod_1.z.string().min(1),
});
const envelopeArraySchema = zod_1.z.array(envelopeSchema);

22
dist/types.d.ts vendored
View file

@ -1,13 +1,33 @@
import { z, type ZodType } from "zod";
export type CultCacheIndexScalar = string | number | boolean | bigint;
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
export type CultCacheDocumentAccessor<TValue> = CultCacheDocumentFieldName<TValue> | ((value: TValue) => CultCacheIndexScalar | null | undefined);
export interface CultCacheDocumentFormatter<TValue> {
encode(value: TValue): Uint8Array;
decode(payload: Uint8Array): TValue;
}
export interface CultCacheDocumentIndexDefinition<TValue> {
name: string;
accessor: CultCacheDocumentAccessor<TValue>;
}
export interface CultCacheDocumentDefinition<TSchema extends ZodType = ZodType> {
type: string;
schema: TSchema;
schemaName?: string;
global?: boolean;
name?: CultCacheDocumentAccessor<z.infer<TSchema>>;
indexes?: Readonly<Record<string, CultCacheDocumentAccessor<z.infer<TSchema>>>> | readonly CultCacheDocumentIndexDefinition<z.infer<TSchema>>[];
formatter?: CultCacheDocumentFormatter<z.infer<TSchema>>;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = z.infer<TDefinition["schema"]>;
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
export interface CultCacheDocumentRegistry<TDefinitions extends readonly AnyCultCacheDocumentDefinition[] = readonly AnyCultCacheDocumentDefinition[]> {
readonly definitions: TDefinitions;
}
export interface CultCacheEnvelope {
key: string;
type: string;
payload: unknown;
payload: Uint8Array;
storedAt: string;
}
export interface PushAllOptions {

2
dist/types.d.ts.map vendored
View file

@ -1 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,KAAK,OAAO,EAAE,MAAM,KAAK,CAAC;AAEtC,MAAM,WAAW,2BAA2B,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,sBAAsB,CAAC,WAAW,SAAS,2BAA2B,IAAI,CAAC,CAAC,KAAK,CAC3F,WAAW,CAAC,QAAQ,CAAC,CACtB,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,CAAC,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjF;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB"}
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,KAAK,OAAO,EAAE,MAAM,KAAK,CAAC;AAEtC,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEtE,MAAM,MAAM,0BAA0B,CAAC,MAAM,IAAI,MAAM,SAAS,MAAM,GAAG,OAAO,CAAC,MAAM,MAAM,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;AAE/G,MAAM,MAAM,yBAAyB,CAAC,MAAM,IACxC,0BAA0B,CAAC,MAAM,CAAC,GAClC,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,oBAAoB,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAEjE,MAAM,WAAW,0BAA0B,CAAC,MAAM;IAChD,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAAC;IAClC,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CAAC;CACrC;AAED,MAAM,WAAW,gCAAgC,CAAC,MAAM;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,yBAAyB,CAAC,MAAM,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,2BAA2B,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,yBAAyB,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IACnD,OAAO,CAAC,EACJ,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GACrE,SAAS,gCAAgC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;IAClE,SAAS,CAAC,EAAE,0BAA0B,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;CAC1D;AAED,MAAM,MAAM,sBAAsB,CAAC,WAAW,SAAS,2BAA2B,IAAI,CAAC,CAAC,KAAK,CAC3F,WAAW,CAAC,QAAQ,CAAC,CACtB,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG,2BAA2B,CAAC,GAAG,CAAC,CAAC;AAE9E,MAAM,WAAW,yBAAyB,CACxC,YAAY,SAAS,SAAS,8BAA8B,EAAE,GAAG,SAAS,8BAA8B,EAAE;IAE1G,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,CAAC,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjF;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB"}

View file

@ -9,7 +9,8 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --pretty false"
"typecheck": "tsc -p tsconfig.json --pretty false",
"test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
},
"keywords": [
"messagepack",

View file

@ -1,6 +1,12 @@
import { decode, encode } from "@msgpack/msgpack";
import type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFormatter,
CultCacheDocumentRegistry,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheStoreRegistration,
@ -11,61 +17,193 @@ type StoreRoute = {
mirrors: CacheBackingStore[];
};
type RegisteredDefinition = {
readonly definition: AnyCultCacheDocumentDefinition;
readonly global: boolean;
readonly formatter: CultCacheDocumentFormatter<unknown>;
nameAccessor?: (value: unknown) => string | undefined;
readonly indexAccessors: Map<string, (value: unknown) => string | undefined>;
};
type HydratedEntry = CultCacheEnvelope & {
value: unknown;
};
type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition;
type AccessorInput = string | ((value: unknown) => unknown);
export class CultCacheBuilder {
readonly #cache = new CultCache();
withDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this {
this.#cache.registerDocumentType(definition);
return this;
}
withRegistry(
registry:
| CultCacheDocumentRegistry
| Iterable<AnyCultCacheDocumentDefinition>,
): this {
this.#cache.registerRegistry(registry);
return this;
}
withBackingStore(
store: CacheBackingStore,
...types: BackingStoreTypeReference[]
): this {
this.#cache.addBackingStore(store, ...types);
return this;
}
withGenericStore(store: CacheBackingStore): this {
this.#cache.addGenericBackingStore(store);
return this;
}
build(): CultCache {
return this.#cache;
}
}
export class CultCache {
readonly #definitions = new Map<string, CultCacheDocumentDefinition>();
readonly #entries = new Map<string, CultCacheEnvelope>();
static readonly GLOBAL_KEY = "__global__";
readonly #definitions = new Map<string, RegisteredDefinition>();
readonly #entries = new Map<string, HydratedEntry>();
readonly #typeEntryIds = new Map<string, Set<string>>();
readonly #nameToKeyMaps = new Map<string, Map<string, string>>();
readonly #indexToKeyMaps = new Map<string, Map<string, string>>();
readonly #globalKeys = new Map<string, string>();
readonly #stores: CultCacheStoreRegistration[] = [];
registerDocumentType<TDefinition extends CultCacheDocumentDefinition>(
static builder(): CultCacheBuilder {
return new CultCacheBuilder();
}
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): TDefinition {
const existing = this.#definitions.get(definition.type);
if (existing && existing !== definition) {
if (existing && existing.definition !== definition) {
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
}
this.#definitions.set(definition.type, definition);
const registered = existing ?? {
definition,
global: definition.global === true,
formatter: this.#createFormatter(definition),
nameAccessor: undefined,
indexAccessors: new Map<string, (value: unknown) => string | undefined>(),
};
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;
}
addBackingStore(store: CacheBackingStore, ...types: string[]): void {
registerRegistry(
registry:
| CultCacheDocumentRegistry
| Iterable<AnyCultCacheDocumentDefinition>,
): this {
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
? registry as Iterable<AnyCultCacheDocumentDefinition>
: (registry as CultCacheDocumentRegistry).definitions;
for (const definition of definitions) {
this.registerDocumentType(definition);
}
return this;
}
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
): TDefinition {
const registered = this.#requireDefinition(definition);
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
this.#rebuildNameLookup(registered);
return definition;
}
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
): TDefinition {
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: CacheBackingStore,
...types: BackingStoreTypeReference[]
): void {
this.#stores.push({
store,
types: [...new Set(types)],
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
});
}
addGenericBackingStore(store: CacheBackingStore): void {
this.addBackingStore(store);
}
async pullAllBackingStores(): Promise<void> {
this.#entries.clear();
this.#resetHydratedState();
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) {
const registered = this.#definitions.get(entry.type);
if (!registered) {
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), {
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
this.#applyHydratedEntry(registered, {
...entry,
payload: parsed,
});
payload,
}, value, "pull");
}
}
}
get<TDefinition extends CultCacheDocumentDefinition>(
get<TDefinition extends AnyCultCacheDocumentDefinition>(
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;
return entry ? (entry.value as CultCacheDocumentValue<TDefinition>) : undefined;
}
getRequired<TDefinition extends CultCacheDocumentDefinition>(
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): CultCacheDocumentValue<TDefinition> {
@ -77,32 +215,115 @@ export class CultCache {
return value;
}
getAll<TDefinition extends CultCacheDocumentDefinition>(
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition> | undefined {
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<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition> {
const value = this.getGlobal(definition);
if (value === undefined) {
throw new Error(`CultCache has no global "${definition.type}" document.`);
}
return value;
}
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): CultCacheDocumentValue<TDefinition>[] {
this.#requireDefinition(definition);
const values: CultCacheDocumentValue<TDefinition>[] = [];
const entryIds = this.#typeEntryIds.get(definition.type);
for (const entry of this.#entries.values()) {
if (entry.type === definition.type) {
values.push(entry.payload as CultCacheDocumentValue<TDefinition>);
if (!entryIds) {
return values;
}
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
values.push(entry.value as CultCacheDocumentValue<TDefinition>);
}
}
return values;
}
async put<TDefinition extends CultCacheDocumentDefinition>(
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): string | undefined {
const registered = this.#requireDefinition(definition);
if (!registered.nameAccessor) {
return undefined;
}
return this.#nameToKeyMaps.get(definition.type)?.get(name);
}
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): string | undefined {
return this.getKeyByName(definition, name);
}
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
name: string,
): CultCacheDocumentValue<TDefinition> | undefined {
const key = this.getKeyByName(definition, name);
return key ? this.get(definition, key) : undefined;
}
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): string | undefined {
this.#requireDefinition(definition);
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
}
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): string | undefined {
return this.getKeyByIndex(definition, indexName, value);
}
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
indexName: string,
value: string,
): CultCacheDocumentValue<TDefinition> | undefined {
const key = this.getKeyByIndex(definition, indexName, value);
return key ? this.get(definition, key) : undefined;
}
async put<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
this.#requireDefinition(definition);
const parsed = definition.schema.parse(value) as CultCacheDocumentValue<TDefinition>;
const registered = this.#requireDefinition(definition);
const payload = registered.formatter.encode(value);
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
const entry: CultCacheEnvelope = {
key,
type: definition.type,
payload: parsed,
payload: this.#cloneBytes(payload),
storedAt: new Date().toISOString(),
};
@ -111,13 +332,32 @@ export class CultCache {
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.#entries.set(this.#entryId(definition.type, key), entry);
this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed;
}
async update<TDefinition extends CultCacheDocumentDefinition>(
async putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
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<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
updater: (
@ -128,11 +368,21 @@ export class CultCache {
return this.put(definition, key, updater(current));
}
async delete<TDefinition extends CultCacheDocumentDefinition>(
async updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
updater: (
current: CultCacheDocumentValue<TDefinition> | undefined,
) => CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const current = this.getGlobal(definition);
return this.putGlobal(definition, updater(current));
}
async delete<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
key: string,
): Promise<boolean> {
this.#requireDefinition(definition);
const registered = this.#requireDefinition(definition);
const entry = this.#entries.get(this.#entryId(definition.type, key));
if (!entry) {
return false;
@ -143,25 +393,110 @@ export class CultCache {
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));
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;
}
snapshot(): CultCacheEnvelope[] {
return [...this.#entries.values()].map((entry) => structuredClone(entry));
async deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): Promise<boolean> {
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;
}
#requireDefinition<TDefinition extends CultCacheDocumentDefinition>(
snapshot(): CultCacheEnvelope[] {
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
}
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
const userFormatter = definition.formatter ?? {
encode: (value: unknown) => encode(value),
decode: (payload: Uint8Array) => decode(payload),
};
return {
encode: (value: unknown) => {
const parsed = definition.schema.parse(value);
return this.#cloneBytes(userFormatter.encode(parsed));
},
decode: (payload: Uint8Array) => {
const decoded = userFormatter.decode(this.#cloneBytes(payload));
return definition.schema.parse(decoded);
},
};
}
#enumerateDefinitionIndexes(
definition: AnyCultCacheDocumentDefinition,
): Array<[string, AccessorInput]> {
if (!definition.indexes) {
return [];
}
if (Array.isArray(definition.indexes)) {
return definition.indexes.map((index) => [index.name, index.accessor as AccessorInput]);
}
return Object.entries(definition.indexes) as Array<[string, AccessorInput]>;
}
#compileAccessor(
type: string,
label: string,
accessor: AccessorInput,
): (value: unknown) => string | undefined {
if (typeof accessor === "function") {
return (value: unknown) => this.#normalizeIndexValue(type, label, accessor(value));
}
return (value: unknown) => {
if (value === null || typeof value !== "object") {
return undefined;
}
const fieldValue = (value as Record<string, unknown>)[accessor];
return this.#normalizeIndexValue(type, `${label} field "${accessor}"`, fieldValue);
};
}
#normalizeIndexValue(
type: string,
label: string,
value: unknown,
): string | undefined {
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<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
): TDefinition {
): RegisteredDefinition {
const registered = this.#definitions.get(definition.type);
if (!registered || registered !== definition) {
if (!registered || registered.definition !== definition) {
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
}
return definition;
return registered;
}
#resolveRoute(type: string): StoreRoute {
@ -180,7 +515,203 @@ export class CultCache {
};
}
#applyHydratedEntry(
registered: RegisteredDefinition,
entry: CultCacheEnvelope,
value: unknown,
source: "pull" | "put",
): void {
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: HydratedEntry = {
...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: RegisteredDefinition,
entry: HydratedEntry,
): void {
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: RegisteredDefinition): void {
this.#rebuildNameLookup(registered);
for (const indexName of registered.indexAccessors.keys()) {
this.#rebuildIndexLookup(registered, indexName);
}
}
#rebuildNameLookup(registered: RegisteredDefinition): void {
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: RegisteredDefinition, indexName: string): void {
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: string): HydratedEntry[] {
const entryIds = this.#typeEntryIds.get(type);
if (!entryIds) {
return [];
}
const entries: HydratedEntry[] = [];
for (const entryId of entryIds) {
const entry = this.#entries.get(entryId);
if (entry) {
entries.push(entry);
}
}
return entries;
}
#getOrCreateTypeEntrySet(type: string): Set<string> {
let set = this.#typeEntryIds.get(type);
if (!set) {
set = new Set<string>();
this.#typeEntryIds.set(type, set);
}
return set;
}
#getOrCreateNameMap(type: string): Map<string, string> {
let map = this.#nameToKeyMaps.get(type);
if (!map) {
map = new Map<string, string>();
this.#nameToKeyMaps.set(type, map);
}
return map;
}
#getOrCreateIndexMap(type: string, indexName: string): Map<string, string> {
const lookupId = this.#indexId(type, indexName);
let map = this.#indexToKeyMaps.get(lookupId);
if (!map) {
map = new Map<string, string>();
this.#indexToKeyMaps.set(lookupId, map);
}
return map;
}
#toEnvelope(entry: HydratedEntry): CultCacheEnvelope {
return {
key: entry.key,
type: entry.type,
payload: this.#cloneBytes(entry.payload),
storedAt: entry.storedAt,
};
}
#resetHydratedState(): void {
this.#entries.clear();
this.#typeEntryIds.clear();
this.#nameToKeyMaps.clear();
this.#indexToKeyMaps.clear();
this.#globalKeys.clear();
}
#entryId(type: string, key: string): string {
return `${type}::${key}`;
}
#indexId(type: string, indexName: string): string {
return `${type}::${indexName}`;
}
#cloneBytes(payload: Uint8Array): Uint8Array {
return new Uint8Array(payload);
}
}

View file

@ -1,6 +1,6 @@
import type { ZodType } from "zod";
import type { CultCacheDocumentDefinition } from "./types";
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentDefinition, CultCacheDocumentRegistry } from "./types";
export function defineDocumentType<TSchema extends ZodType>(
definition: CultCacheDocumentDefinition<TSchema>,
@ -9,5 +9,36 @@ export function defineDocumentType<TSchema extends ZodType>(
throw new Error("CultCache document types must declare a non-empty type.");
}
return definition;
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
}
if (Array.isArray(definition.indexes)) {
const seen = new Set<string>();
for (const index of definition.indexes) {
if (!index.name || index.name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
if (seen.has(index.name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
}
seen.add(index.name);
}
} else if (definition.indexes) {
for (const name of Object.keys(definition.indexes)) {
if (!name || name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
}
}
return Object.freeze({ ...definition });
}
export function defineDocumentRegistry<
TDefinitions extends readonly AnyCultCacheDocumentDefinition[],
>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions> {
return Object.freeze({
definitions: Object.freeze([...definitions]) as TDefinitions,
});
}

View file

@ -1,11 +1,18 @@
export { CultCache } from "./cult-cache";
export { defineDocumentType } from "./document";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { defineDocumentRegistry, defineDocumentType } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFieldName,
CultCacheDocumentFormatter,
CultCacheDocumentIndexDefinition,
CultCacheDocumentRegistry,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheIndexScalar,
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";

View file

@ -9,7 +9,7 @@ import type { CacheBackingStore, CultCacheEnvelope, PushAllOptions } from "./typ
const envelopeSchema = z.object({
key: z.string().min(1),
type: z.string().min(1),
payload: z.unknown(),
payload: z.instanceof(Uint8Array),
storedAt: z.string().min(1),
});

View file

@ -1,18 +1,51 @@
import { z, type ZodType } from "zod";
export type CultCacheIndexScalar = string | number | boolean | bigint;
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
export type CultCacheDocumentAccessor<TValue> =
| CultCacheDocumentFieldName<TValue>
| ((value: TValue) => CultCacheIndexScalar | null | undefined);
export interface CultCacheDocumentFormatter<TValue> {
encode(value: TValue): Uint8Array;
decode(payload: Uint8Array): TValue;
}
export interface CultCacheDocumentIndexDefinition<TValue> {
name: string;
accessor: CultCacheDocumentAccessor<TValue>;
}
export interface CultCacheDocumentDefinition<TSchema extends ZodType = ZodType> {
type: string;
schema: TSchema;
schemaName?: string;
global?: boolean;
name?: CultCacheDocumentAccessor<z.infer<TSchema>>;
indexes?:
| Readonly<Record<string, CultCacheDocumentAccessor<z.infer<TSchema>>>>
| readonly CultCacheDocumentIndexDefinition<z.infer<TSchema>>[];
formatter?: CultCacheDocumentFormatter<z.infer<TSchema>>;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = z.infer<
TDefinition["schema"]
>;
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
export interface CultCacheDocumentRegistry<
TDefinitions extends readonly AnyCultCacheDocumentDefinition[] = readonly AnyCultCacheDocumentDefinition[],
> {
readonly definitions: TDefinitions;
}
export interface CultCacheEnvelope {
key: string;
type: string;
payload: unknown;
payload: Uint8Array;
storedAt: string;
}

181
test/cult-cache.test.ts Normal file
View file

@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { encode } from "@msgpack/msgpack";
import { z } from "zod";
import { CultCache } from "../src/cult-cache";
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { CacheBackingStore, CultCacheEnvelope } from "../src/types";
test("CultCache supports registry bootstrap, global documents, and lookup by name and index", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({
name: z.string(),
category: z.string(),
value: z.number().int(),
}),
name: "name",
indexes: {
category: "category",
},
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
retries: z.number().int().nonnegative(),
}),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "item:potion", {
name: "Potion",
category: "Consumable",
value: 50,
});
await cache.putGlobal(settingsDocument, {
theme: "ash",
retries: 3,
});
assert.deepEqual(cache.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.equal(cache.getKeyByIndex(itemDocument, "category", "Consumable"), "item:potion");
assert.deepEqual(cache.getGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
const snapshot = cache.snapshot();
assert.equal(snapshot.length, 2);
assert.ok(snapshot.every((entry) => entry.payload instanceof Uint8Array && entry.payload.length > 0));
const reloaded = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
});
test("CultCache can register name and index lookups after entries already exist", async () => {
const noteDocument = defineDocumentType({
type: "note",
schema: z.object({
title: z.string(),
author: z.string(),
body: z.string(),
}),
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const cache = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(noteDocument, "note:hello", {
title: "Hello",
author: "ari",
body: "world",
});
cache.registerNameLookup(noteDocument, "title");
cache.registerIndex(noteDocument, "author", "author");
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
});
test("CultCache fails closed on unknown or illegal persisted polymorphic state", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
}),
global: true,
});
class InMemoryStore implements CacheBackingStore {
constructor(private readonly entries: CultCacheEnvelope[]) {}
async pullAll(): Promise<CultCacheEnvelope[]> {
return this.entries;
}
async push(): Promise<void> {
throw new Error("not used");
}
async delete(): Promise<void> {
throw new Error("not used");
}
}
const cacheWithUnknownType = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "mystery",
type: "forbidden",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithUnknownType.pullAllBackingStores(),
/No schema is registered for persisted document type "forbidden"\./,
);
const cacheWithDuplicateGlobal = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "one",
type: "settings",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
{
key: "two",
type: "settings",
payload: encode({ theme: "ember" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithDuplicateGlobal.pullAllBackingStores(),
/has multiple persisted entries/,
);
});

View file

@ -17,5 +17,8 @@
},
"include": [
"src/**/*.ts"
],
"exclude": [
"test/**/*.ts"
]
}

14
tsconfig.test.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist-test",
"declaration": false,
"declarationMap": false
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": []
}