Skip to content

@studnicky/entity-store

Normalized, ID-indexed entity collection with CRUD operations and O(1) lookup.

Install

bash
pnpm add @studnicky/entity-store

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

EntityStore does not fetch or persist data — the caller supplies entities via upsertOne/upsertMany/setAll, and the store tracks a normalized, ID-indexed collection with O(1) lookup:

ts
import { EntityStore } from '../src/index.js';

class TaskSelector {
  static selectId(task: TaskEntity.Type): string {
    const { id } = task;
    return id;
  }
}

class TelemetryStore extends EntityStore<TaskEntity.Type> {
  readonly upsertEvents: { 'id': string; 'title': string }[] = [];
  readonly removeEvents: { 'id': string }[] = [];
  readonly replaceAllEvents: { 'count': number }[] = [];

  static tracked(): TelemetryStore {
    return new TelemetryStore({ 'selectId': TaskSelector.selectId });
  }

  protected override onUpsert(id: string, entity: TaskEntity.Type): void {
    console.log(`[entity-store] upsert id=${id} title=${entity.title}`);
    this.upsertEvents.push({ 'id': id, 'title': entity.title });
  }

  protected override onRemove(id: string): void {
    console.log(`[entity-store] remove id=${id}`);
    this.removeEvents.push({ 'id': id });
  }

  protected override onReplaceAll(count: number): void {
    console.log(`[entity-store] replaceAll count=${count}`);
    this.replaceAllEvents.push({ 'count': count });
  }
}

const store = TelemetryStore.tracked();

await store.upsertOne({ 'id': 'task-1', 'title': 'Write proposal' });
await store.upsertMany([
  { 'id': 'task-2', 'title': 'Review PR' },
  { 'id': 'task-3', 'title': 'Ship release' }
]);
await store.removeOne('task-2');
await store.removeOne('missing'); // no-op — does not fire onRemove
await store.setAll([
  { 'id': 'task-4', 'title': 'Deploy' },
  { 'id': 'task-5', 'title': 'Announce' }
]);

console.log('All entities:', store.getAll());
console.log('Upsert events:', store.upsertEvents);
console.log('Remove events:', store.removeEvents);
console.log('ReplaceAll events:', store.replaceAllEvents);

Observability hooks

EntityStore exposes protected lifecycle hooks that a subclass can override to add logging, timing, or metrics without any changes to the caller. The base class never calls any logger or metrics library. All hooks are no-ops by default.

HookWhen it firesArgs
onUpsert(id, entity)Once per entity from upsertOne/upsertMany, after it is storedid: TId, entity: TEntity
onRemove(id)removeOne/removeMany removes an entity that actually existed — not called for absent idsid: TId
onReplaceAll(count)Once from setAll, with the count of entities in the new collectioncount: number

A hook override that throws or rejects does not abort the mutation that triggered it — the failure is recorded instead of propagating; inspect it via hookErrorCount (a running total) and getHookErrors() (a defensive copy of every recorded { hookName, cause } entry), backed internally by @studnicky/errors's HookInvoker.

typescript
store.hookErrorCount; // 1 after a throwing onUpsert override
store.getHookErrors(); // [{ hookName: 'onUpsert', cause: Error }]

No eviction or TTL

Unlike @studnicky/cache's LruCache, EntityStore is deliberately unbounded and non-evicting: no capacity limit, no TTL, no lazy expiry. It is the in-memory mirror of a normalized collection your application already owns, not a cache of derived or externally-sourced values. Reach for @studnicky/cache when eviction or staleness semantics are needed instead.

API

ExportTypeDescription
EntityStore<TEntity, TId>classNormalized entity collection; generic entity and id types
EntityStoreBuilder<TEntity, TId>classFluent builder for EntityStore; produced by EntityStore.builder()
EntityStoreOptionsType<TEntity, TId>type{ selectId, sortComparer? }

EntityStore<TEntity, TId>

MemberSignatureDescription
createstatic create<TEntity, TId>(options): EntityStore<TEntity, TId>Constructs a store from options
builderstatic builder<TEntity, TId>(): EntityStoreBuilder<TEntity, TId>Returns a fluent builder for constructing a store
sizeget size(): numberCurrent entity count
upsertOne(entity: TEntity) => voidDerives id via selectId; inserts or overwrites
upsertMany(entities: readonly TEntity[]) => voidUpserts every entity in array order
removeOne(id: TId) => booleanRemoves an entity; returns whether it existed
removeMany(ids: readonly TId[]) => numberRemoves each id; returns the count actually removed
setAll(entities: readonly TEntity[]) => voidReplaces the entire collection
getAll() => readonly TEntity[]Returns every entity, sorted by sortComparer if configured
getById(id: TId) => TEntity | undefinedReturns the entity for id, or undefined
getIds() => readonly TId[]Returns every id, in insertion order
hookErrorCountget hookErrorCount(): numberCount of hook failures recorded since construction
getHookErrors() => readonly HookErrorEntryType[]Defensive copy of every hook failure recorded since construction

EntityStoreBuilder<TEntity, TId>

MemberSignatureDescription
withSelectId(value: (entity: TEntity) => TId) => thisSets the id-derivation function (required before build())
withSortComparer(value: (a: TEntity, b: TEntity) => number) => thisSets the optional sort comparator for getAll()
build() => EntityStore<TEntity, TId>Constructs the store; throws if selectId was not set

Source on GitHub