From 0c430c3c0ed12c20040bd155b7111fd2df2b18de Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 03:11:02 +0100 Subject: [PATCH] Initial CultCacheTS port --- .gitignore | 2 + README.md | 65 +++++++ package-lock.json | 70 +++++++ package.json | 32 ++++ src/cult-cache.ts | 186 +++++++++++++++++++ src/document.ts | 13 ++ src/index.ts | 11 ++ src/single-file-messagepack-backing-store.ts | 111 +++++++++++ src/types.ts | 33 ++++ tsconfig.json | 21 +++ 10 files changed, 544 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cult-cache.ts create mode 100644 src/document.ts create mode 100644 src/index.ts create mode 100644 src/single-file-messagepack-backing-store.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e09ddc --- /dev/null +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28e5d72 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac5c7b9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cult-cache.ts b/src/cult-cache.ts new file mode 100644 index 0000000..0b6aee2 --- /dev/null +++ b/src/cult-cache.ts @@ -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(); + readonly #entries = new Map(); + readonly #stores: CultCacheStoreRegistration[] = []; + + registerDocumentType( + 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 { + 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( + definition: TDefinition, + key: string, + ): CultCacheDocumentValue | undefined { + this.#requireDefinition(definition); + const entry = this.#entries.get(this.#entryId(definition.type, key)); + return entry ? (entry.payload as CultCacheDocumentValue) : undefined; + } + + getRequired( + definition: TDefinition, + key: string, + ): CultCacheDocumentValue { + const value = this.get(definition, key); + if (value === undefined) { + throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`); + } + + return value; + } + + getAll( + definition: TDefinition, + ): CultCacheDocumentValue[] { + this.#requireDefinition(definition); + const values: CultCacheDocumentValue[] = []; + + for (const entry of this.#entries.values()) { + if (entry.type === definition.type) { + values.push(entry.payload as CultCacheDocumentValue); + } + } + + return values; + } + + async put( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + ): Promise> { + this.#requireDefinition(definition); + const parsed = definition.schema.parse(value) as CultCacheDocumentValue; + 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( + definition: TDefinition, + key: string, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + const current = this.get(definition, key); + return this.put(definition, key, updater(current)); + } + + async delete( + definition: TDefinition, + key: string, + ): Promise { + 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( + 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}`; + } +} diff --git a/src/document.ts b/src/document.ts new file mode 100644 index 0000000..3453de6 --- /dev/null +++ b/src/document.ts @@ -0,0 +1,13 @@ +import type { ZodType } from "zod"; + +import type { CultCacheDocumentDefinition } from "./types"; + +export function defineDocumentType( + definition: CultCacheDocumentDefinition, +): CultCacheDocumentDefinition { + if (!definition.type || definition.type.trim().length === 0) { + throw new Error("CultCache document types must declare a non-empty type."); + } + + return definition; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..be07cbe --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/single-file-messagepack-backing-store.ts b/src/single-file-messagepack-backing-store.ts new file mode 100644 index 0000000..607f9dc --- /dev/null +++ b/src/single-file-messagepack-backing-store.ts @@ -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 = Promise.resolve(); + + constructor(filePath: string) { + this.filePath = resolve(filePath); + } + + async pullAll(): Promise { + 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 { + 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 { + 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 { + 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(operation: () => Promise): Promise { + 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 { + 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; + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d621d30 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,33 @@ +import { z, type ZodType } from "zod"; + +export interface CultCacheDocumentDefinition { + type: string; + schema: TSchema; +} + +export type CultCacheDocumentValue = 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; + push(entry: CultCacheEnvelope): Promise; + delete(entry: CultCacheEnvelope): Promise; + pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise; +} + +export interface CultCacheStoreRegistration { + store: CacheBackingStore; + types: string[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..500d9a5 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +}