diff --git a/.gitignore b/.gitignore index c2658d7..4c6cdaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist-test/ diff --git a/README.md b/README.md index 3e09ddc..c3cd5ea 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/dist/cult-cache.d.ts b/dist/cult-cache.d.ts index 4bfc3dc..2eb1d5c 100644 --- a/dist/cult-cache.d.ts +++ b/dist/cult-cache.d.ts @@ -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(definition: TDefinition): this; + withRegistry(registry: CultCacheDocumentRegistry | Iterable): this; + withBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): this; + withGenericStore(store: CacheBackingStore): this; + build(): CultCache; +} export declare class CultCache { #private; - registerDocumentType(definition: TDefinition): TDefinition; - addBackingStore(store: CacheBackingStore, ...types: string[]): void; + static readonly GLOBAL_KEY = "__global__"; + static builder(): CultCacheBuilder; + registerDocumentType(definition: TDefinition): TDefinition; + registerRegistry(registry: CultCacheDocumentRegistry | Iterable): this; + registerNameLookup(definition: TDefinition, accessor: CultCacheDocumentAccessor>): TDefinition; + registerIndex(definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor>): TDefinition; + addBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): void; + addGenericBackingStore(store: CacheBackingStore): 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; + get(definition: TDefinition, key: string): CultCacheDocumentValue | undefined; + getRequired(definition: TDefinition, key: string): CultCacheDocumentValue; + getGlobal(definition: TDefinition): CultCacheDocumentValue | undefined; + getRequiredGlobal(definition: TDefinition): CultCacheDocumentValue; + getAll(definition: TDefinition): CultCacheDocumentValue[]; + getKeyByName(definition: TDefinition, name: string): string | undefined; + getIdByName(definition: TDefinition, name: string): string | undefined; + getByName(definition: TDefinition, name: string): CultCacheDocumentValue | undefined; + getKeyByIndex(definition: TDefinition, indexName: string, value: string): string | undefined; + getIdByIndex(definition: TDefinition, indexName: string, value: string): string | undefined; + getByIndex(definition: TDefinition, indexName: string, value: string): CultCacheDocumentValue | undefined; + put(definition: TDefinition, key: string, value: CultCacheDocumentValue): Promise>; + putGlobal(definition: TDefinition, value: CultCacheDocumentValue): Promise>; + update(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue | undefined) => CultCacheDocumentValue): Promise>; + updateGlobal(definition: TDefinition, updater: (current: CultCacheDocumentValue | undefined) => CultCacheDocumentValue): Promise>; + delete(definition: TDefinition, key: string): Promise; + deleteGlobal(definition: TDefinition): Promise; snapshot(): CultCacheEnvelope[]; } +export {}; //# 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 index a3e33c7..af85f4a 100644 --- a/dist/cult-cache.d.ts.map +++ b/dist/cult-cache.d.ts.map @@ -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"} \ No newline at end of file +{"version":3,"file":"cult-cache.d.ts","sourceRoot":"","sources":["../src/cult-cache.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/cult-cache.js b/dist/cult-cache.js index 5d6d667..32094fa 100644 --- a/dist/cult-cache.js +++ b/dist/cult-cache.js @@ -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; diff --git a/dist/document.d.ts b/dist/document.d.ts index af49566..c54a785 100644 --- a/dist/document.d.ts +++ b/dist/document.d.ts @@ -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(definition: CultCacheDocumentDefinition): CultCacheDocumentDefinition; +export declare function defineDocumentRegistry(...definitions: TDefinitions): CultCacheDocumentRegistry; //# 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 index 8f95f77..0a41029 100644 --- a/dist/document.d.ts.map +++ b/dist/document.d.ts.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/dist/document.js b/dist/document.js index fc85853..b9fe238 100644 --- a/dist/document.js +++ b/dist/document.js @@ -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]), + }); } diff --git a/dist/index.d.ts b/dist/index.d.ts index 2bdcc5e..c41e651 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -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 \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 6136081..4ede6ca 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 01b4290..72ff556 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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; } }); diff --git a/dist/single-file-messagepack-backing-store.js b/dist/single-file-messagepack-backing-store.js index 43d079e..a685a67 100644 --- a/dist/single-file-messagepack-backing-store.js +++ b/dist/single-file-messagepack-backing-store.js @@ -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); diff --git a/dist/types.d.ts b/dist/types.d.ts index 1631f8c..55c047b 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,13 +1,33 @@ import { z, type ZodType } from "zod"; +export type CultCacheIndexScalar = string | number | boolean | bigint; +export type CultCacheDocumentFieldName = TValue extends object ? Extract : never; +export type CultCacheDocumentAccessor = CultCacheDocumentFieldName | ((value: TValue) => CultCacheIndexScalar | null | undefined); +export interface CultCacheDocumentFormatter { + encode(value: TValue): Uint8Array; + decode(payload: Uint8Array): TValue; +} +export interface CultCacheDocumentIndexDefinition { + name: string; + accessor: CultCacheDocumentAccessor; +} export interface CultCacheDocumentDefinition { type: string; schema: TSchema; + schemaName?: string; + global?: boolean; + name?: CultCacheDocumentAccessor>; + indexes?: Readonly>>> | readonly CultCacheDocumentIndexDefinition>[]; + formatter?: CultCacheDocumentFormatter>; } export type CultCacheDocumentValue = z.infer; +export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition; +export interface CultCacheDocumentRegistry { + readonly definitions: TDefinitions; +} export interface CultCacheEnvelope { key: string; type: string; - payload: unknown; + payload: Uint8Array; storedAt: string; } export interface PushAllOptions { diff --git a/dist/types.d.ts.map b/dist/types.d.ts.map index 97cdf7a..8d94009 100644 --- a/dist/types.d.ts.map +++ b/dist/types.d.ts.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/package.json b/package.json index ac5c7b9..63a6742 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cult-cache.ts b/src/cult-cache.ts index 0b6aee2..02e3681 100644 --- a/src/cult-cache.ts +++ b/src/cult-cache.ts @@ -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; + nameAccessor?: (value: unknown) => string | undefined; + readonly indexAccessors: Map 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(definition: TDefinition): this { + this.#cache.registerDocumentType(definition); + return this; + } + + withRegistry( + registry: + | CultCacheDocumentRegistry + | Iterable, + ): 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(); - readonly #entries = new Map(); + static readonly GLOBAL_KEY = "__global__"; + + readonly #definitions = new Map(); + readonly #entries = new Map(); + readonly #typeEntryIds = new Map>(); + readonly #nameToKeyMaps = new Map>(); + readonly #indexToKeyMaps = new Map>(); + readonly #globalKeys = new Map(); readonly #stores: CultCacheStoreRegistration[] = []; - registerDocumentType( + static builder(): CultCacheBuilder { + return new CultCacheBuilder(); + } + + registerDocumentType( 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 | 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, + ): this { + const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry)) + ? registry as Iterable + : (registry as CultCacheDocumentRegistry).definitions; + + for (const definition of definitions) { + this.registerDocumentType(definition); + } + + return this; + } + + registerNameLookup( + definition: TDefinition, + accessor: CultCacheDocumentAccessor>, + ): TDefinition { + const registered = this.#requireDefinition(definition); + registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor); + this.#rebuildNameLookup(registered); + return definition; + } + + registerIndex( + definition: TDefinition, + indexName: string, + accessor: CultCacheDocumentAccessor>, + ): 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 { - 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( + 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; + return entry ? (entry.value as CultCacheDocumentValue) : undefined; } - getRequired( + getRequired( definition: TDefinition, key: string, ): CultCacheDocumentValue { @@ -77,32 +215,115 @@ export class CultCache { return value; } - getAll( + getGlobal( + definition: TDefinition, + ): CultCacheDocumentValue | 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( + definition: TDefinition, + ): CultCacheDocumentValue { + const value = this.getGlobal(definition); + if (value === undefined) { + throw new Error(`CultCache has no global "${definition.type}" document.`); + } + + return value; + } + + getAll( definition: TDefinition, ): CultCacheDocumentValue[] { this.#requireDefinition(definition); const values: CultCacheDocumentValue[] = []; + 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); + if (!entryIds) { + return values; + } + + for (const entryId of entryIds) { + const entry = this.#entries.get(entryId); + if (entry) { + values.push(entry.value as CultCacheDocumentValue); } } return values; } - async put( + getKeyByName( + 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( + definition: TDefinition, + name: string, + ): string | undefined { + return this.getKeyByName(definition, name); + } + + getByName( + definition: TDefinition, + name: string, + ): CultCacheDocumentValue | undefined { + const key = this.getKeyByName(definition, name); + return key ? this.get(definition, key) : undefined; + } + + getKeyByIndex( + definition: TDefinition, + indexName: string, + value: string, + ): string | undefined { + this.#requireDefinition(definition); + return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value); + } + + getIdByIndex( + definition: TDefinition, + indexName: string, + value: string, + ): string | undefined { + return this.getKeyByIndex(definition, indexName, value); + } + + getByIndex( + definition: TDefinition, + indexName: string, + value: string, + ): CultCacheDocumentValue | undefined { + const key = this.getKeyByIndex(definition, indexName, value); + return key ? this.get(definition, key) : undefined; + } + + async put( definition: TDefinition, key: string, value: CultCacheDocumentValue, ): Promise> { - this.#requireDefinition(definition); - const parsed = definition.schema.parse(value) as CultCacheDocumentValue; + const registered = this.#requireDefinition(definition); + const payload = registered.formatter.encode(value); + const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; 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( + async putGlobal( + definition: TDefinition, + value: CultCacheDocumentValue, + ): Promise> { + 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: TDefinition, key: string, updater: ( @@ -128,11 +368,21 @@ export class CultCache { return this.put(definition, key, updater(current)); } - async delete( + async updateGlobal( + definition: TDefinition, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + const current = this.getGlobal(definition); + return this.putGlobal(definition, updater(current)); + } + + async delete( definition: TDefinition, key: string, ): Promise { - 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( + definition: TDefinition, + ): Promise { + 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( + snapshot(): CultCacheEnvelope[] { + return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry)); + } + + #createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter { + 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)[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( 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 { + let set = this.#typeEntryIds.get(type); + if (!set) { + set = new Set(); + this.#typeEntryIds.set(type, set); + } + return set; + } + + #getOrCreateNameMap(type: string): Map { + let map = this.#nameToKeyMaps.get(type); + if (!map) { + map = new Map(); + this.#nameToKeyMaps.set(type, map); + } + return map; + } + + #getOrCreateIndexMap(type: string, indexName: string): Map { + 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: 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); + } } diff --git a/src/document.ts b/src/document.ts index 3453de6..e92575d 100644 --- a/src/document.ts +++ b/src/document.ts @@ -1,6 +1,6 @@ import type { ZodType } from "zod"; -import type { CultCacheDocumentDefinition } from "./types"; +import type { AnyCultCacheDocumentDefinition, CultCacheDocumentDefinition, CultCacheDocumentRegistry } from "./types"; export function defineDocumentType( definition: CultCacheDocumentDefinition, @@ -9,5 +9,36 @@ export function defineDocumentType( 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 }); +} + +export function defineDocumentRegistry< + TDefinitions extends readonly AnyCultCacheDocumentDefinition[], +>(...definitions: TDefinitions): CultCacheDocumentRegistry { + return Object.freeze({ + definitions: Object.freeze([...definitions]) as TDefinitions, + }); } diff --git a/src/index.ts b/src/index.ts index be07cbe..bf05774 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/single-file-messagepack-backing-store.ts b/src/single-file-messagepack-backing-store.ts index 607f9dc..6618d7d 100644 --- a/src/single-file-messagepack-backing-store.ts +++ b/src/single-file-messagepack-backing-store.ts @@ -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), }); diff --git a/src/types.ts b/src/types.ts index d621d30..0d81aab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,51 @@ import { z, type ZodType } from "zod"; +export type CultCacheIndexScalar = string | number | boolean | bigint; + +export type CultCacheDocumentFieldName = TValue extends object ? Extract : never; + +export type CultCacheDocumentAccessor = + | CultCacheDocumentFieldName + | ((value: TValue) => CultCacheIndexScalar | null | undefined); + +export interface CultCacheDocumentFormatter { + encode(value: TValue): Uint8Array; + decode(payload: Uint8Array): TValue; +} + +export interface CultCacheDocumentIndexDefinition { + name: string; + accessor: CultCacheDocumentAccessor; +} + export interface CultCacheDocumentDefinition { type: string; schema: TSchema; + schemaName?: string; + global?: boolean; + name?: CultCacheDocumentAccessor>; + indexes?: + | Readonly>>> + | readonly CultCacheDocumentIndexDefinition>[]; + formatter?: CultCacheDocumentFormatter>; } export type CultCacheDocumentValue = z.infer< TDefinition["schema"] >; +export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition; + +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; } diff --git a/test/cult-cache.test.ts b/test/cult-cache.test.ts new file mode 100644 index 0000000..e1013dd --- /dev/null +++ b/test/cult-cache.test.ts @@ -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 { + return this.entries; + } + + async push(): Promise { + throw new Error("not used"); + } + + async delete(): Promise { + 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/, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 500d9a5..9bcf61b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,8 @@ }, "include": [ "src/**/*.ts" + ], + "exclude": [ + "test/**/*.ts" ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..fc3ca71 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist-test", + "declaration": false, + "declarationMap": false + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +}