diff --git a/.gitignore b/.gitignore index 1eae0cf..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -dist/ node_modules/ diff --git a/dist/cult-cache.d.ts b/dist/cult-cache.d.ts new file mode 100644 index 0000000..4bfc3dc --- /dev/null +++ b/dist/cult-cache.d.ts @@ -0,0 +1,15 @@ +import type { CacheBackingStore, CultCacheDocumentDefinition, CultCacheDocumentValue, CultCacheEnvelope } from "./types"; +export declare class CultCache { + #private; + registerDocumentType(definition: TDefinition): TDefinition; + addBackingStore(store: CacheBackingStore, ...types: string[]): void; + pullAllBackingStores(): Promise; + get(definition: TDefinition, key: string): CultCacheDocumentValue | undefined; + getRequired(definition: TDefinition, key: string): CultCacheDocumentValue; + getAll(definition: TDefinition): CultCacheDocumentValue[]; + put(definition: TDefinition, key: string, value: CultCacheDocumentValue): Promise>; + update(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue | undefined) => CultCacheDocumentValue): Promise>; + delete(definition: TDefinition, key: string): Promise; + snapshot(): CultCacheEnvelope[]; +} +//# sourceMappingURL=cult-cache.d.ts.map \ No newline at end of file diff --git a/dist/cult-cache.d.ts.map b/dist/cult-cache.d.ts.map new file mode 100644 index 0000000..a3e33c7 --- /dev/null +++ b/dist/cult-cache.d.ts.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/cult-cache.js b/dist/cult-cache.js new file mode 100644 index 0000000..5d6d667 --- /dev/null +++ b/dist/cult-cache.js @@ -0,0 +1,126 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CultCache = void 0; +class CultCache { + #definitions = new Map(); + #entries = new Map(); + #stores = []; + registerDocumentType(definition) { + 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, ...types) { + this.#stores.push({ + store, + types: [...new Set(types)], + }); + } + async pullAllBackingStores() { + 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, key) { + this.#requireDefinition(definition); + const entry = this.#entries.get(this.#entryId(definition.type, key)); + return entry ? entry.payload : undefined; + } + getRequired(definition, key) { + const value = this.get(definition, key); + if (value === undefined) { + throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`); + } + return value; + } + getAll(definition) { + this.#requireDefinition(definition); + const values = []; + for (const entry of this.#entries.values()) { + if (entry.type === definition.type) { + values.push(entry.payload); + } + } + return values; + } + async put(definition, key, value) { + this.#requireDefinition(definition); + const parsed = definition.schema.parse(value); + const entry = { + 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, key, updater) { + const current = this.get(definition, key); + return this.put(definition, key, updater(current)); + } + async delete(definition, key) { + 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() { + return [...this.#entries.values()].map((entry) => structuredClone(entry)); + } + #requireDefinition(definition) { + 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) { + 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, key) { + return `${type}::${key}`; + } +} +exports.CultCache = CultCache; diff --git a/dist/document.d.ts b/dist/document.d.ts new file mode 100644 index 0000000..af49566 --- /dev/null +++ b/dist/document.d.ts @@ -0,0 +1,4 @@ +import type { ZodType } from "zod"; +import type { CultCacheDocumentDefinition } from "./types"; +export declare function defineDocumentType(definition: CultCacheDocumentDefinition): CultCacheDocumentDefinition; +//# sourceMappingURL=document.d.ts.map \ No newline at end of file diff --git a/dist/document.d.ts.map b/dist/document.d.ts.map new file mode 100644 index 0000000..8f95f77 --- /dev/null +++ b/dist/document.d.ts.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/document.js b/dist/document.js new file mode 100644 index 0000000..fc85853 --- /dev/null +++ b/dist/document.js @@ -0,0 +1,9 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.defineDocumentType = defineDocumentType; +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; +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..2bdcc5e --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,5 @@ +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"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map new file mode 100644 index 0000000..6136081 --- /dev/null +++ b/dist/index.d.ts.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..01b4290 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,9 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SingleFileMessagePackBackingStore = exports.defineDocumentType = exports.CultCache = void 0; +var cult_cache_1 = require("./cult-cache"); +Object.defineProperty(exports, "CultCache", { enumerable: true, get: function () { return cult_cache_1.CultCache; } }); +var document_1 = require("./document"); +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; } }); diff --git a/dist/single-file-messagepack-backing-store.d.ts b/dist/single-file-messagepack-backing-store.d.ts new file mode 100644 index 0000000..6c6518e --- /dev/null +++ b/dist/single-file-messagepack-backing-store.d.ts @@ -0,0 +1,11 @@ +import type { CacheBackingStore, CultCacheEnvelope, PushAllOptions } from "./types"; +export declare class SingleFileMessagePackBackingStore implements CacheBackingStore { + #private; + readonly filePath: string; + constructor(filePath: string); + pullAll(): Promise; + push(entry: CultCacheEnvelope): Promise; + delete(entry: CultCacheEnvelope): Promise; + pushAll(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise; +} +//# sourceMappingURL=single-file-messagepack-backing-store.d.ts.map \ No newline at end of file diff --git a/dist/single-file-messagepack-backing-store.d.ts.map b/dist/single-file-messagepack-backing-store.d.ts.map new file mode 100644 index 0000000..5f266f3 --- /dev/null +++ b/dist/single-file-messagepack-backing-store.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAWpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAcvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} \ No newline at end of file diff --git a/dist/single-file-messagepack-backing-store.js b/dist/single-file-messagepack-backing-store.js new file mode 100644 index 0000000..43d079e --- /dev/null +++ b/dist/single-file-messagepack-backing-store.js @@ -0,0 +1,90 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SingleFileMessagePackBackingStore = void 0; +const promises_1 = require("node:fs/promises"); +const node_path_1 = require("node:path"); +const msgpack_1 = require("@msgpack/msgpack"); +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(), + storedAt: zod_1.z.string().min(1), +}); +const envelopeArraySchema = zod_1.z.array(envelopeSchema); +class SingleFileMessagePackBackingStore { + filePath; + #writeQueue = Promise.resolve(); + constructor(filePath) { + this.filePath = (0, node_path_1.resolve)(filePath); + } + async pullAll() { + try { + const data = await (0, promises_1.readFile)(this.filePath); + return envelopeArraySchema.parse((0, msgpack_1.decode)(data)); + } + catch (error) { + const code = error.code; + if (code === "ENOENT") { + return []; + } + throw error; + } + } + async push(entry) { + 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) { + 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, options = {}) { + await this.#enqueue(async () => { + if (options.soft) { + try { + await (0, promises_1.readFile)(this.filePath); + return; + } + catch (error) { + const code = error.code; + if (code !== "ENOENT") { + throw error; + } + } + } + await this.#writeAll(entries); + }); + } + async #enqueue(operation) { + let result; + const next = this.#writeQueue.then(async () => { + result = await operation(); + }); + this.#writeQueue = next.then(() => undefined, () => undefined); + await next; + return result; + } + async #writeAll(entries) { + await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + try { + await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(entries)); + await (0, promises_1.rename)(tempPath, this.filePath); + } + catch (error) { + await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined); + throw error; + } + } +} +exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore; diff --git a/dist/types.d.ts b/dist/types.d.ts new file mode 100644 index 0000000..1631f8c --- /dev/null +++ b/dist/types.d.ts @@ -0,0 +1,26 @@ +import { z, type ZodType } from "zod"; +export interface CultCacheDocumentDefinition { + type: string; + schema: TSchema; +} +export type CultCacheDocumentValue = z.infer; +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[]; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/types.d.ts.map b/dist/types.d.ts.map new file mode 100644 index 0000000..97cdf7a --- /dev/null +++ b/dist/types.d.ts.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true });