Initial CultCacheTS port
This commit is contained in:
commit
0c430c3c0e
10 changed files with 544 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
||||
65
README.md
Normal file
65
README.md
Normal 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
70
package-lock.json
generated
Normal 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
32
package.json
Normal 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
186
src/cult-cache.ts
Normal 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
13
src/document.ts
Normal 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
11
src/index.ts
Normal 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";
|
||||
111
src/single-file-messagepack-backing-store.ts
Normal file
111
src/single-file-messagepack-backing-store.ts
Normal 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
33
src/types.ts
Normal 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
21
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue