Expand CultCacheTS toward full CultCache ergonomics
This commit is contained in:
parent
9562d6a603
commit
be0a96db7b
23 changed files with 1488 additions and 119 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
node_modules/
|
||||
dist-test/
|
||||
|
|
|
|||
189
README.md
189
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.
|
||||
|
|
|
|||
45
dist/cult-cache.d.ts
vendored
45
dist/cult-cache.d.ts
vendored
|
|
@ -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<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this;
|
||||
withRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
|
||||
withBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): this;
|
||||
withGenericStore(store: CacheBackingStore): this;
|
||||
build(): CultCache;
|
||||
}
|
||||
export declare class CultCache {
|
||||
#private;
|
||||
registerDocumentType<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition): TDefinition;
|
||||
addBackingStore(store: CacheBackingStore, ...types: string[]): void;
|
||||
static readonly GLOBAL_KEY = "__global__";
|
||||
static builder(): CultCacheBuilder;
|
||||
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): TDefinition;
|
||||
registerRegistry(registry: CultCacheDocumentRegistry | Iterable<AnyCultCacheDocumentDefinition>): this;
|
||||
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
|
||||
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>): TDefinition;
|
||||
addBackingStore(store: CacheBackingStore, ...types: BackingStoreTypeReference[]): void;
|
||||
addGenericBackingStore(store: CacheBackingStore): void;
|
||||
pullAllBackingStores(): Promise<void>;
|
||||
get<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition> | undefined;
|
||||
getRequired<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition>;
|
||||
getAll<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>[];
|
||||
put<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
update<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
delete<TDefinition extends CultCacheDocumentDefinition>(definition: TDefinition, key: string): Promise<boolean>;
|
||||
get<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition> | undefined;
|
||||
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): CultCacheDocumentValue<TDefinition>;
|
||||
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition> | undefined;
|
||||
getRequiredGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>;
|
||||
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): CultCacheDocumentValue<TDefinition>[];
|
||||
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
|
||||
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): string | undefined;
|
||||
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, name: string): CultCacheDocumentValue<TDefinition> | undefined;
|
||||
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
|
||||
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): string | undefined;
|
||||
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, indexName: string, value: string): CultCacheDocumentValue<TDefinition> | undefined;
|
||||
put<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, value: CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
update<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, updater: (current: CultCacheDocumentValue<TDefinition> | undefined) => CultCacheDocumentValue<TDefinition>): Promise<CultCacheDocumentValue<TDefinition>>;
|
||||
delete<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition, key: string): Promise<boolean>;
|
||||
deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): Promise<boolean>;
|
||||
snapshot(): CultCacheEnvelope[];
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=cult-cache.d.ts.map
|
||||
2
dist/cult-cache.d.ts.map
vendored
2
dist/cult-cache.d.ts.map
vendored
|
|
@ -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"}
|
||||
{"version":3,"file":"cult-cache.d.ts","sourceRoot":"","sources":["../src/cult-cache.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,8BAA8B,EAC9B,iBAAiB,EACjB,yBAAyB,EAGzB,yBAAyB,EACzB,sBAAsB,EACtB,iBAAiB,EAElB,MAAM,SAAS,CAAC;AAmBjB,KAAK,yBAAyB,GAAG,MAAM,GAAG,8BAA8B,CAAC;AAGzE,qBAAa,gBAAgB;;IAG3B,gBAAgB,CAAC,WAAW,SAAS,8BAA8B,EAAE,UAAU,EAAE,WAAW,GAAG,IAAI;IAKnG,YAAY,CACV,QAAQ,EACJ,yBAAyB,GACzB,QAAQ,CAAC,8BAA8B,CAAC,GAC3C,IAAI;IAKP,gBAAgB,CACd,KAAK,EAAE,iBAAiB,EACxB,GAAG,KAAK,EAAE,yBAAyB,EAAE,GACpC,IAAI;IAKP,gBAAgB,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI;IAKhD,KAAK,IAAI,SAAS;CAGnB;AAED,qBAAa,SAAS;;IACpB,MAAM,CAAC,QAAQ,CAAC,UAAU,gBAAgB;IAU1C,MAAM,CAAC,OAAO,IAAI,gBAAgB;IAIlC,oBAAoB,CAAC,WAAW,SAAS,8BAA8B,EACrE,UAAU,EAAE,WAAW,GACtB,WAAW;IA8Bd,gBAAgB,CACd,QAAQ,EACJ,yBAAyB,GACzB,QAAQ,CAAC,8BAA8B,CAAC,GAC3C,IAAI;IAYP,kBAAkB,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,EACvB,QAAQ,EAAE,yBAAyB,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC,GACvE,WAAW;IAOd,aAAa,CAAC,WAAW,SAAS,8BAA8B,EAC9D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,yBAAyB,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC,GACvE,WAAW;IAcd,eAAe,CACb,KAAK,EAAE,iBAAiB,EACxB,GAAG,KAAK,EAAE,yBAAyB,EAAE,GACpC,IAAI;IAOP,sBAAsB,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI;IAIhD,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB3C,GAAG,CAAC,WAAW,SAAS,8BAA8B,EACpD,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAMlD,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAC5D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,sBAAsB,CAAC,WAAW,CAAC;IAStC,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAUlD,iBAAiB,CAAC,WAAW,SAAS,8BAA8B,EAClE,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC;IAStC,MAAM,CAAC,WAAW,SAAS,8BAA8B,EACvD,UAAU,EAAE,WAAW,GACtB,sBAAsB,CAAC,WAAW,CAAC,EAAE;IAmBxC,YAAY,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS;IASrB,WAAW,CAAC,WAAW,SAAS,8BAA8B,EAC5D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS;IAIrB,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,EACvB,IAAI,EAAE,MAAM,GACX,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAKlD,aAAa,CAAC,WAAW,SAAS,8BAA8B,EAC9D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS;IAKrB,YAAY,CAAC,WAAW,SAAS,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS;IAIrB,UAAU,CAAC,WAAW,SAAS,8BAA8B,EAC3D,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,sBAAsB,CAAC,WAAW,CAAC,GAAG,SAAS;IAK5C,GAAG,CAAC,WAAW,SAAS,8BAA8B,EAC1D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,sBAAsB,CAAC,WAAW,CAAC,GACzC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IA6BzC,SAAS,CAAC,WAAW,SAAS,8BAA8B,EAChE,UAAU,EAAE,WAAW,EACvB,KAAK,EAAE,sBAAsB,CAAC,WAAW,CAAC,GACzC,OAAO,CAAC,sBAAsB,CAAC,WAAW,CAAC,CAAC;IASzC,MAAM,CAAC,WAAW,SAAS,8BAA8B,EAC7D,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,YAAY,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,EACvB,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,8BAA8B,EAC7D,UAAU,EAAE,WAAW,EACvB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC;IAmBb,YAAY,CAAC,WAAW,SAAS,8BAA8B,EACnE,UAAU,EAAE,WAAW,GACtB,OAAO,CAAC,OAAO,CAAC;IAUnB,QAAQ,IAAI,iBAAiB,EAAE;CA8ShC"}
|
||||
408
dist/cult-cache.js
vendored
408
dist/cult-cache.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
3
dist/document.d.ts
vendored
3
dist/document.d.ts
vendored
|
|
@ -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<TSchema extends ZodType>(definition: CultCacheDocumentDefinition<TSchema>): CultCacheDocumentDefinition<TSchema>;
|
||||
export declare function defineDocumentRegistry<TDefinitions extends readonly AnyCultCacheDocumentDefinition[]>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions>;
|
||||
//# sourceMappingURL=document.d.ts.map
|
||||
2
dist/document.d.ts.map
vendored
2
dist/document.d.ts.map
vendored
|
|
@ -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"}
|
||||
{"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"}
|
||||
30
dist/document.js
vendored
30
dist/document.js
vendored
|
|
@ -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]),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
6
dist/index.d.ts
vendored
6
dist/index.d.ts
vendored
|
|
@ -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
|
||||
2
dist/index.d.ts.map
vendored
2
dist/index.d.ts.map
vendored
|
|
@ -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"}
|
||||
{"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"}
|
||||
4
dist/index.js
vendored
4
dist/index.js
vendored
|
|
@ -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; } });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
22
dist/types.d.ts
vendored
22
dist/types.d.ts
vendored
|
|
@ -1,13 +1,33 @@
|
|||
import { z, type ZodType } from "zod";
|
||||
export type CultCacheIndexScalar = string | number | boolean | bigint;
|
||||
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
|
||||
export type CultCacheDocumentAccessor<TValue> = CultCacheDocumentFieldName<TValue> | ((value: TValue) => CultCacheIndexScalar | null | undefined);
|
||||
export interface CultCacheDocumentFormatter<TValue> {
|
||||
encode(value: TValue): Uint8Array;
|
||||
decode(payload: Uint8Array): TValue;
|
||||
}
|
||||
export interface CultCacheDocumentIndexDefinition<TValue> {
|
||||
name: string;
|
||||
accessor: CultCacheDocumentAccessor<TValue>;
|
||||
}
|
||||
export interface CultCacheDocumentDefinition<TSchema extends ZodType = ZodType> {
|
||||
type: string;
|
||||
schema: TSchema;
|
||||
schemaName?: string;
|
||||
global?: boolean;
|
||||
name?: CultCacheDocumentAccessor<z.infer<TSchema>>;
|
||||
indexes?: Readonly<Record<string, CultCacheDocumentAccessor<z.infer<TSchema>>>> | readonly CultCacheDocumentIndexDefinition<z.infer<TSchema>>[];
|
||||
formatter?: CultCacheDocumentFormatter<z.infer<TSchema>>;
|
||||
}
|
||||
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = z.infer<TDefinition["schema"]>;
|
||||
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
|
||||
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;
|
||||
}
|
||||
export interface PushAllOptions {
|
||||
|
|
|
|||
2
dist/types.d.ts.map
vendored
2
dist/types.d.ts.map
vendored
|
|
@ -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"}
|
||||
{"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"}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
nameAccessor?: (value: unknown) => string | undefined;
|
||||
readonly indexAccessors: Map<string, (value: unknown) => 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<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this {
|
||||
this.#cache.registerDocumentType(definition);
|
||||
return this;
|
||||
}
|
||||
|
||||
withRegistry(
|
||||
registry:
|
||||
| CultCacheDocumentRegistry
|
||||
| Iterable<AnyCultCacheDocumentDefinition>,
|
||||
): 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<string, CultCacheDocumentDefinition>();
|
||||
readonly #entries = new Map<string, CultCacheEnvelope>();
|
||||
static readonly GLOBAL_KEY = "__global__";
|
||||
|
||||
readonly #definitions = new Map<string, RegisteredDefinition>();
|
||||
readonly #entries = new Map<string, HydratedEntry>();
|
||||
readonly #typeEntryIds = new Map<string, Set<string>>();
|
||||
readonly #nameToKeyMaps = new Map<string, Map<string, string>>();
|
||||
readonly #indexToKeyMaps = new Map<string, Map<string, string>>();
|
||||
readonly #globalKeys = new Map<string, string>();
|
||||
readonly #stores: CultCacheStoreRegistration[] = [];
|
||||
|
||||
registerDocumentType<TDefinition extends CultCacheDocumentDefinition>(
|
||||
static builder(): CultCacheBuilder {
|
||||
return new CultCacheBuilder();
|
||||
}
|
||||
|
||||
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
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, (value: unknown) => 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<AnyCultCacheDocumentDefinition>,
|
||||
): this {
|
||||
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
|
||||
? registry as Iterable<AnyCultCacheDocumentDefinition>
|
||||
: (registry as CultCacheDocumentRegistry).definitions;
|
||||
|
||||
for (const definition of definitions) {
|
||||
this.registerDocumentType(definition);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
|
||||
): TDefinition {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
|
||||
this.#rebuildNameLookup(registered);
|
||||
return definition;
|
||||
}
|
||||
|
||||
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
|
||||
): 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<void> {
|
||||
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<TDefinition extends CultCacheDocumentDefinition>(
|
||||
get<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
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;
|
||||
return entry ? (entry.value as CultCacheDocumentValue<TDefinition>) : undefined;
|
||||
}
|
||||
|
||||
getRequired<TDefinition extends CultCacheDocumentDefinition>(
|
||||
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
|
|
@ -77,32 +215,115 @@ export class CultCache {
|
|||
return value;
|
||||
}
|
||||
|
||||
getAll<TDefinition extends CultCacheDocumentDefinition>(
|
||||
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition> | 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<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
const value = this.getGlobal(definition);
|
||||
if (value === undefined) {
|
||||
throw new Error(`CultCache has no global "${definition.type}" document.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition>[] {
|
||||
this.#requireDefinition(definition);
|
||||
const values: CultCacheDocumentValue<TDefinition>[] = [];
|
||||
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<TDefinition>);
|
||||
if (!entryIds) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (const entryId of entryIds) {
|
||||
const entry = this.#entries.get(entryId);
|
||||
if (entry) {
|
||||
values.push(entry.value as CultCacheDocumentValue<TDefinition>);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
async put<TDefinition extends CultCacheDocumentDefinition>(
|
||||
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
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<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
return this.getKeyByName(definition, name);
|
||||
}
|
||||
|
||||
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
name: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
const key = this.getKeyByName(definition, name);
|
||||
return key ? this.get(definition, key) : undefined;
|
||||
}
|
||||
|
||||
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): string | undefined {
|
||||
this.#requireDefinition(definition);
|
||||
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
|
||||
}
|
||||
|
||||
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): string | undefined {
|
||||
return this.getKeyByIndex(definition, indexName, value);
|
||||
}
|
||||
|
||||
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
const key = this.getKeyByIndex(definition, indexName, value);
|
||||
return key ? this.get(definition, key) : undefined;
|
||||
}
|
||||
|
||||
async put<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
this.#requireDefinition(definition);
|
||||
const parsed = definition.schema.parse(value) as CultCacheDocumentValue<TDefinition>;
|
||||
const registered = this.#requireDefinition(definition);
|
||||
const payload = registered.formatter.encode(value);
|
||||
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
|
||||
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<TDefinition extends CultCacheDocumentDefinition>(
|
||||
async putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
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<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
updater: (
|
||||
|
|
@ -128,11 +368,21 @@ export class CultCache {
|
|||
return this.put(definition, key, updater(current));
|
||||
}
|
||||
|
||||
async delete<TDefinition extends CultCacheDocumentDefinition>(
|
||||
async updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
updater: (
|
||||
current: CultCacheDocumentValue<TDefinition> | undefined,
|
||||
) => CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const current = this.getGlobal(definition);
|
||||
return this.putGlobal(definition, updater(current));
|
||||
}
|
||||
|
||||
async delete<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
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<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): Promise<boolean> {
|
||||
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<TDefinition extends CultCacheDocumentDefinition>(
|
||||
snapshot(): CultCacheEnvelope[] {
|
||||
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
|
||||
}
|
||||
|
||||
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
|
||||
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<string, unknown>)[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<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
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<string> {
|
||||
let set = this.#typeEntryIds.get(type);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
this.#typeEntryIds.set(type, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
#getOrCreateNameMap(type: string): Map<string, string> {
|
||||
let map = this.#nameToKeyMaps.get(type);
|
||||
if (!map) {
|
||||
map = new Map<string, string>();
|
||||
this.#nameToKeyMaps.set(type, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
#getOrCreateIndexMap(type: string, indexName: string): Map<string, string> {
|
||||
const lookupId = this.#indexId(type, indexName);
|
||||
let map = this.#indexToKeyMaps.get(lookupId);
|
||||
if (!map) {
|
||||
map = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ZodType } from "zod";
|
||||
|
||||
import type { CultCacheDocumentDefinition } from "./types";
|
||||
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentDefinition, CultCacheDocumentRegistry } from "./types";
|
||||
|
||||
export function defineDocumentType<TSchema extends ZodType>(
|
||||
definition: CultCacheDocumentDefinition<TSchema>,
|
||||
|
|
@ -9,5 +9,36 @@ export function defineDocumentType<TSchema extends ZodType>(
|
|||
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<string>();
|
||||
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<TDefinitions> {
|
||||
return Object.freeze({
|
||||
definitions: Object.freeze([...definitions]) as TDefinitions,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
11
src/index.ts
11
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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
35
src/types.ts
35
src/types.ts
|
|
@ -1,18 +1,51 @@
|
|||
import { z, type ZodType } from "zod";
|
||||
|
||||
export type CultCacheIndexScalar = string | number | boolean | bigint;
|
||||
|
||||
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
|
||||
|
||||
export type CultCacheDocumentAccessor<TValue> =
|
||||
| CultCacheDocumentFieldName<TValue>
|
||||
| ((value: TValue) => CultCacheIndexScalar | null | undefined);
|
||||
|
||||
export interface CultCacheDocumentFormatter<TValue> {
|
||||
encode(value: TValue): Uint8Array;
|
||||
decode(payload: Uint8Array): TValue;
|
||||
}
|
||||
|
||||
export interface CultCacheDocumentIndexDefinition<TValue> {
|
||||
name: string;
|
||||
accessor: CultCacheDocumentAccessor<TValue>;
|
||||
}
|
||||
|
||||
export interface CultCacheDocumentDefinition<TSchema extends ZodType = ZodType> {
|
||||
type: string;
|
||||
schema: TSchema;
|
||||
schemaName?: string;
|
||||
global?: boolean;
|
||||
name?: CultCacheDocumentAccessor<z.infer<TSchema>>;
|
||||
indexes?:
|
||||
| Readonly<Record<string, CultCacheDocumentAccessor<z.infer<TSchema>>>>
|
||||
| readonly CultCacheDocumentIndexDefinition<z.infer<TSchema>>[];
|
||||
formatter?: CultCacheDocumentFormatter<z.infer<TSchema>>;
|
||||
}
|
||||
|
||||
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = z.infer<
|
||||
TDefinition["schema"]
|
||||
>;
|
||||
|
||||
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
181
test/cult-cache.test.ts
Normal file
181
test/cult-cache.test.ts
Normal file
|
|
@ -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<CultCacheEnvelope[]> {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
async push(): Promise<void> {
|
||||
throw new Error("not used");
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -17,5 +17,8 @@
|
|||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
14
tsconfig.test.json
Normal file
14
tsconfig.test.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-test",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
Loading…
Reference in a new issue