Initial CultCacheTS port

This commit is contained in:
Meta Vens 2026-05-06 03:11:02 +01:00
commit 0c430c3c0e
10 changed files with 544 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# 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.
This is not trying to cosplay as an ORM. 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
## What It Does
- 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
## 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.
## Example
```ts
import { z } from "zod";
import {
CultCache,
SingleFileMessagePackBackingStore,
defineDocumentType,
} from "cultcache-ts";
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
retries: z.number().int().nonnegative(),
}),
});
const cache = new CultCache();
cache.registerDocumentType(settingsDocument);
cache.addBackingStore(new SingleFileMessagePackBackingStore("cache.msgpack"));
await cache.pullAllBackingStores();
await cache.put(settingsDocument, "app", {
theme: "ash",
retries: 3,
});
const settings = cache.getRequired(settingsDocument, "app");
```
## Current Scope
- `CultCache`
- `SingleFileMessagePackBackingStore`
- typed document definitions via `defineDocumentType`
That is enough for consumers to stop writing their own sad little JSON-file bureaucracies and get back to the interesting part.

70
package-lock.json generated Normal file
View file

@ -0,0 +1,70 @@
{
"name": "cultcache-ts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cultcache-ts",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "cultcache-ts",
"version": "0.1.0",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --pretty false"
},
"keywords": [
"messagepack",
"schema",
"cache",
"persistence",
"zod",
"typescript"
],
"author": "metacratic",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}

186
src/cult-cache.ts Normal file
View file

@ -0,0 +1,186 @@
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}`;
}
}

13
src/document.ts Normal file
View file

@ -0,0 +1,13 @@
import type { ZodType } from "zod";
import type { CultCacheDocumentDefinition } from "./types";
export function defineDocumentType<TSchema extends ZodType>(
definition: CultCacheDocumentDefinition<TSchema>,
): CultCacheDocumentDefinition<TSchema> {
if (!definition.type || definition.type.trim().length === 0) {
throw new Error("CultCache document types must declare a non-empty type.");
}
return definition;
}

11
src/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { CultCache } from "./cult-cache";
export { defineDocumentType } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export type {
CacheBackingStore,
CultCacheDocumentDefinition,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";

View file

@ -0,0 +1,111 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import type { CacheBackingStore, CultCacheEnvelope, PushAllOptions } from "./types";
const envelopeSchema = z.object({
key: z.string().min(1),
type: z.string().min(1),
payload: z.unknown(),
storedAt: z.string().min(1),
});
const envelopeArraySchema = z.array(envelopeSchema);
export class SingleFileMessagePackBackingStore implements CacheBackingStore {
readonly filePath: string;
#writeQueue: Promise<void> = Promise.resolve();
constructor(filePath: string) {
this.filePath = resolve(filePath);
}
async pullAll(): Promise<CultCacheEnvelope[]> {
try {
const data = await readFile(this.filePath);
return envelopeArraySchema.parse(decode(data)) as CultCacheEnvelope[];
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return [];
}
throw error;
}
}
async push(entry: CultCacheEnvelope): Promise<void> {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
);
filtered.push(entry);
await this.#writeAll(filtered);
});
}
async delete(entry: CultCacheEnvelope): Promise<void> {
await this.#enqueue(async () => {
const existing = await this.pullAll();
const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
);
await this.#writeAll(filtered);
});
}
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => {
if (options.soft) {
try {
await readFile(this.filePath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
throw error;
}
}
}
await this.#writeAll(entries);
});
}
async #enqueue<T>(operation: () => Promise<T>): Promise<T> {
let result!: T;
const next = this.#writeQueue.then(async () => {
result = await operation();
});
this.#writeQueue = next.then(
() => undefined,
() => undefined,
);
await next;
return result;
}
async #writeAll(entries: CultCacheEnvelope[]): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
try {
await writeFile(tempPath, encode(entries));
await rename(tempPath, this.filePath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}
}

33
src/types.ts Normal file
View file

@ -0,0 +1,33 @@
import { z, type ZodType } from "zod";
export interface CultCacheDocumentDefinition<TSchema extends ZodType = ZodType> {
type: string;
schema: TSchema;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = z.infer<
TDefinition["schema"]
>;
export interface CultCacheEnvelope {
key: string;
type: string;
payload: unknown;
storedAt: string;
}
export interface PushAllOptions {
soft?: boolean;
}
export interface CacheBackingStore {
pullAll(): Promise<CultCacheEnvelope[]>;
push(entry: CultCacheEnvelope): Promise<void>;
delete(entry: CultCacheEnvelope): Promise<void>;
pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise<void>;
}
export interface CultCacheStoreRegistration {
store: CacheBackingStore;
types: string[];
}

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": [
"ES2022"
],
"strict": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "src",
"outDir": "dist"
},
"include": [
"src/**/*.ts"
]
}