Skip to content

stash

stash creates a typed in-memory cache with explicit key hashing, optional metadata, and TTL-based garbage collection.

Source Code

View Source Code
ts
import { Scheduler, type SchedulerLike } from '../async';

type CacheKey = readonly unknown[];

type CacheRecord<K extends CacheKey, T, M> = {
  key: K;
  meta: M | undefined;
  value: T;
};

type GcTask = {
  controller: AbortController;
  token: number;
};

export type CacheOptions<K extends CacheKey = CacheKey> = {
  hash: (key: K) => string;
  onError?: (error: unknown) => void;
  scheduler?: Pick<SchedulerLike, 'postTask'>;
};

export type CacheSetOptions<M> = {
  meta?: M;
  ttlMs?: number;
};

export type Stash<T, K extends CacheKey = CacheKey, M = never> = {
  cancelGc: (key: K) => void;
  clear: () => void;
  delete: (key: K) => boolean;
  entries: () => IterableIterator<[K, T]>;
  get: (key: K) => T | undefined;
  getEntry: (key: K) => Readonly<{ meta: M | undefined; value: T }> | undefined;
  getOrSet: (key: K, factory: () => T, options?: CacheSetOptions<M>) => T;
  scheduleGc: (key: K, delayMs: number) => void;
  set: (key: K, value: T, options?: CacheSetOptions<M>) => void;
  size: () => number;
  touch: (key: K, ttlMs: number) => boolean;
};

/**
 * Creates a generic key-value cache with automatic garbage collection and observer support.
 *
 * @example
 * ```ts
 * const myCache = stash<string>({ hash: (key) => JSON.stringify(key) });
 * myCache.set(['user', 1], 'John Doe');
 * const value = myCache.get(['user', 1]); // 'John Doe'
 * myCache.scheduleGc(['user', 1], 5000); // Auto-delete after 5s
 * ```
 *
 * @template T - The type of values stored in the cache.
 *
 * @returns A cache instance with get, set, delete, clear, and GC methods.
 */
export function stash<T, K extends CacheKey = CacheKey, M = never>(options: CacheOptions<K>): Stash<T, K, M> {
  const store = new Map<string, CacheRecord<K, T, M>>();
  const gcTasks = new Map<string, GcTask>();
  const scheduler = options.scheduler ?? new Scheduler();
  const hash = options.hash;
  let gcToken = 0;

  function cancelGcByHash(keyHash: string): void {
    gcTasks.get(keyHash)?.controller.abort();
    gcTasks.delete(keyHash);
  }

  function deleteByHash(keyHash: string): boolean {
    const existed = store.has(keyHash);

    cancelGcByHash(keyHash);
    store.delete(keyHash);

    return existed;
  }

  function get(key: K): T | undefined {
    return store.get(hash(key))?.value;
  }

  function getEntry(key: K): Readonly<{ meta: M | undefined; value: T }> | undefined {
    const entry = store.get(hash(key));

    if (!entry) {
      return undefined;
    }

    return { meta: entry.meta, value: entry.value };
  }

  function set(key: K, value: T, options?: CacheSetOptions<M>): void {
    const keyHash = hash(key);
    const existing = store.get(keyHash);

    cancelGcByHash(keyHash);
    store.set(keyHash, {
      key,
      meta: options && 'meta' in options ? options.meta : existing?.meta,
      value,
    });

    if (options?.ttlMs !== undefined) {
      scheduleGc(key, options.ttlMs);
    }
  }

  function getOrSet(key: K, factory: () => T, options?: CacheSetOptions<M>): T {
    const existing = getEntry(key);

    if (existing) {
      return existing.value;
    }

    const value = factory();

    set(key, value, options);

    return value;
  }

  function del(key: K): boolean {
    return deleteByHash(hash(key));
  }

  function clear(): void {
    for (const task of gcTasks.values()) task.controller.abort();
    store.clear();
    gcTasks.clear();
  }

  function size(): number {
    return store.size;
  }

  function scheduleGcByHash(keyHash: string, delayMs: number): void {
    cancelGcByHash(keyHash);

    if (delayMs === Number.POSITIVE_INFINITY) {
      return;
    }

    if (!Number.isFinite(delayMs)) {
      throw new TypeError('stash.scheduleGc expects a finite number or Infinity');
    }

    if (delayMs <= 0) {
      deleteByHash(keyHash);

      return;
    }

    if (!store.has(keyHash)) {
      return;
    }

    const controller = new AbortController();
    const token = ++gcToken;

    gcTasks.set(keyHash, { controller, token });
    void scheduler
      .postTask(
        () => {
          const current = gcTasks.get(keyHash);

          if (!current || current.token !== token) {
            return;
          }

          gcTasks.delete(keyHash);

          deleteByHash(keyHash);
        },
        {
          delay: delayMs,
          priority: 'background',
          signal: controller.signal,
        },
      )
      .catch((error) => {
        if (error instanceof DOMException && error.name === 'AbortError') {
          return;
        }

        options.onError?.(error);
      });
  }

  function scheduleGc(key: K, delayMs: number): void {
    scheduleGcByHash(hash(key), delayMs);
  }

  function cancelGc(key: K): void {
    cancelGcByHash(hash(key));
  }

  function touch(key: K, ttlMs: number): boolean {
    const keyHash = hash(key);

    if (!store.has(keyHash)) {
      return false;
    }

    scheduleGcByHash(keyHash, ttlMs);

    return true;
  }

  function* entries(): IterableIterator<[K, T]> {
    for (const record of store.values()) {
      yield [record.key, record.value];
    }
  }

  return {
    cancelGc,
    clear,
    delete: del,
    entries,
    get,
    getEntry,
    getOrSet,
    scheduleGc,
    set,
    size,
    touch,
  };
}

Features

  • Type-safe: Generic typing for values, keys, and metadata.
  • Explicit hashing: You provide hash(key) to control key stability.
  • TTL support: Set TTL inline (set(..., { ttlMs })) or later with scheduleGc.
  • Metadata support: Optional metadata per key.
  • Ergonomic helpers: has, getOrSet, touch, entries, keys, values.

API

ts
type CacheOptions<K extends readonly unknown[]> = {
  hash: (key: K) => string;
  onError?: (error: unknown) => void;
};

type CacheSetOptions<M> = {
  ttlMs?: number; // finite number or Infinity
  meta?: M;
};

declare function stash<T, K extends readonly unknown[] = readonly unknown[], M = never>(
  options: CacheOptions<K>,
): {
  get: (key: K) => T | undefined;
  set: (key: K, value: T, options?: CacheSetOptions<M>) => void;
  getOrSet: (key: K, factory: () => T, options?: CacheSetOptions<M>) => T;
  touch: (key: K, ttlMs: number) => boolean;
  delete: (key: K) => boolean;
  clear: () => void;
  size: () => number;
  scheduleGc: (key: K, delayMs: number) => void;
  cancelGc: (key: K) => void;
  getEntry: (key: K) => { value: T; meta: M | undefined } | undefined;
  entries: () => IterableIterator<[K, T]>;
};

Parameters

  • options.hash (required): deterministic hash function for your key type.
  • options.onError (optional): called when scheduler task submission fails for non-abort reasons.

Examples

Basic Cache Usage

ts
import { stash } from '@vielzeug/toolkit';

const userCache = stash<{ name: string; email: string }>({
  hash: (key) => JSON.stringify(key),
});

userCache.set(['user', 123], { name: 'Alice', email: 'alice@example.com' });

const user = userCache.get(['user', 123]);
console.log(user?.name); // 'Alice'
console.log(userCache.size()); // 1

TTL + GC

ts
import { stash } from '@vielzeug/toolkit';

const sessionCache = stash<string>({
  hash: (key) => JSON.stringify(key),
});

sessionCache.set(['session', 'abc123'], 'user-data', { ttlMs: 5 * 60 * 1000 });

// Extend the TTL if still active
sessionCache.touch(['session', 'abc123'], 10 * 60 * 1000);

Metadata

ts
import { stash } from '@vielzeug/toolkit';

type QueryMeta = { staleTime: number; gcTime: number; enabled: boolean };

const apiCache = stash<unknown, readonly unknown[], QueryMeta>({
  hash: (key) => JSON.stringify(key),
});

apiCache.set(['api', '/users'], { data: [] }, { meta: { staleTime: 60000, gcTime: 300000, enabled: true } });

const meta = apiCache.getEntry(['api', '/users'])?.meta;
console.log(meta?.staleTime); // 60000

Lazy Creation

ts
import { stash } from '@vielzeug/toolkit';

const c = stash<number>({ hash: (key) => JSON.stringify(key) });

const value = c.getOrSet(['answer'], () => 42);
console.log(value); // 42

Iteration

ts
import { stash } from '@vielzeug/toolkit';

const c = stash<number>({ hash: (key) => JSON.stringify(key) });
c.set(['a'], 1);
c.set(['b'], 2);

for (const [key, value] of c.entries()) {
  console.log(key, value);
}

Implementation Notes

  • Hashing strategy is caller-defined via hash(key).
  • GC tasks are revision-guarded to avoid stale timer races.
  • ttlMs supports Infinity to disable eviction for a key.
  • The cache is in-memory only.

See Also

  • memo: Memoize function results.
  • merge: Deep merge objects.