npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

entity-repository

v0.6.0

Published

Type-safe entity caching and state management with RxJS and React

Readme

entity-repository

Type-safe entity caching and state management with RxJS observables and React integration.

Features

  • Type-safe entity management - Full TypeScript generics for entity definitions and ID types
  • In-memory caching - Fast Map-based cache by table and entity ID
  • RxJS-based reactivity - Observable streams for real-time updates
  • Query layer - RecordQuery for single entities, ListQuery for filtered/sorted lists
  • React integration - Context provider and hooks for seamless React usage
  • Request deduplication - Prevents duplicate fetch requests for the same entity
  • Write deduplication - set skips the event when the record is deep-equal to what's cached
  • Optimistic writes - RepositoryWriter patches the cache first, serialises per-entity, and rolls back on failure
  • Real-time event system - Insert/update/delete events for reactive list updates

Installation

npm install entity-repository rxjs

Quick Start

1. Define your entity types

import type { EntityConfig } from "entity-repository";

// Define the shape of your entities
type Entities = {
  users: { id: string; name: string; email: string };
  posts: { id: string; title: string; authorId: string; createdAt: string };
};

// Configure which field is the ID for each entity
const entityConfig = {
  users: { id: "id" },
  posts: { id: "id" },
} as const satisfies EntityConfig<Entities>;

type MyEntityConfig = typeof entityConfig;

2. Create the repository

import { Repository } from "entity-repository";

const repository = new Repository<Entities, MyEntityConfig>({
  entities: entityConfig,
});

3. Basic operations

// Store an entity
repository.set("users", { id: "1", name: "Alice", email: "[email protected]" });

// Get an entity (returns null if not cached)
const user = repository.get("users", { id: "1" });

// Fetch with caching (fetches only if not cached, deduplicates concurrent requests)
const user = await repository.fetch("users", { id: "1" }, async (id) => {
  const response = await fetch(`/api/users/${id.id}`);
  return response.json();
});

// Delete an entity
repository.del("users", { id: "1" });

4. Reactive subscriptions

// Subscribe to entity changes
const observable = repository.getObservable("users", { id: "1" });
observable.subscribe((user) => {
  console.log("User changed:", user);
});

// Subscribe to all events for a table (insert/update/delete)
repository.getEvents("users").subscribe((event) => {
  if (event.type === "insert") console.log("New user:", event.new);
  if (event.type === "update") console.log("Updated:", event.old, "->", event.new);
  if (event.type === "delete") console.log("Deleted:", event.old);
});

React Integration

Setup

import { useMemo } from "react";
import { createRepositoryContext, Repository } from "entity-repository";

// Create typed context and hooks
export const {
  RepositoryProvider,
  useRepository,
  useRepositoryQuery,
  useRepositoryListQuery,
  useSubscribedState,
} = createRepositoryContext<Entities, MyEntityConfig>();

// In your app root
function App() {
  const repository = useMemo(
    () => new Repository<Entities, MyEntityConfig>({ entities: entityConfig }),
    []
  );

  return (
    <RepositoryProvider repository={repository}>
      <YourApp />
    </RepositoryProvider>
  );
}

useRepositoryQuery - Single entity

function UserProfile({ userId }: { userId: string }) {
  const { entity: user, status } = useRepositoryQuery(
    "users",
    { id: userId },
    async (id) => {
      const response = await fetch(`/api/users/${id.id}`);
      return response.json();
    }
  );

  if (status === "fetching") return <div>Loading...</div>;
  if (status === "error") return <div>Error loading user</div>;
  if (!user) return <div>User not found</div>;

  return <div>{user.name}</div>;
}

createEntityLoader + useEntity - Batched, cache-first single-entity reads

useRepositoryQuery takes a fetcher per callsite, so N components reading N ids fire N requests — and the usual "fix" (fall back to fetching the whole collection) is worse. createEntityLoader is a DataLoader over one table: a cache hit resolves with no request, a concurrent duplicate id reuses the in-flight promise, and everything else coalesces into ONE batch call.

Declare the loader once per table at module scope, then read through useEntity:

const usersLoader = createEntityLoader(
  "users",
  (ids) => fetch("/api/users/by-ids", {
    method: "POST",
    body: JSON.stringify({ ids }),
  }).then((r) => r.json()),
  // Optional: debounce so loads arriving on separate ticks merge too.
  // Omit it and each synchronous render burst becomes one batch.
  { debounceMs: 10 },
);

function UserName({ userId }: { userId: string }) {
  const { entity: user, status } = useEntity("users", { id: userId }, usersLoader);

  if (status === "error") return <div>Failed to load</div>;
  if (!user) return <div>…</div>;
  return <div>{user.name}</div>;
}

Rendering 500 rows of <UserName /> issues one request, not 500 — and rows whose user was already seeded (by a list fetch, a realtime event, an earlier batch) issue none. Because the reading component is also the loading component, no row depends on some sibling having fetched its data first.

Omit the loader argument for a cache-only subscription, when something else owns loading. A batch that fails rejects every load() waiting on it, so useEntity reports status: "error" rather than rendering an empty entity forever; the ids are released so the next attempt retries them.

useRepositoryListQuery - Entity lists with filter/sort

function UserPosts({ authorId }: { authorId: string }) {
  const { records: posts, status } = useRepositoryListQuery(
    "posts",
    { authorId },  // param - query re-runs when this changes
    {
      filter: (post) => post.authorId === authorId,
      order: (a, b) => b.createdAt.localeCompare(a.createdAt),
    },
    async (param) => {
      const response = await fetch(`/api/posts?author=${param.authorId}`);
      return response.json();
    }
  );

  if (status === "fetching" && posts.length === 0) return <div>Loading...</div>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

useSubscribedState - Subscribe to any RxJS observable

import { interval } from "rxjs";

function Timer() {
  const seconds = useSubscribedState(interval(1000), 0);
  return <div>Seconds: {seconds}</div>;
}

Real-time Updates Integration

The repository emits events when entities change. Connect to your real-time backend:

// Example with Supabase Realtime
useEffect(() => {
  const channel = supabase
    .channel("changes")
    .on("postgres_changes", { event: "*", schema: "public" }, (payload) => {
      const table = payload.table as keyof Entities;

      if (payload.eventType === "DELETE") {
        repository.del(table, { id: payload.old.id });
      } else {
        repository.set(table, payload.new);
      }
    })
    .subscribe();

  return () => supabase.removeChannel(channel);
}, [repository, supabase]);

Advanced Usage

RecordQuery - Standalone single-entity query

const query = repository.recordQuery("users", { id: "1" }, async (id) => {
  const response = await fetch(`/api/users/${id.id}`);
  return response.json();
});

// Subscribe to state changes
query.$state.subscribe((state) => {
  console.log("Status:", state.status);
  console.log("Entity:", state.entity);
});

// Manually trigger a fetch
await query.fetch();

// Cleanup when done
query.dispose();

ListQuery - Standalone list query

const query = repository.listQuery(
  "posts",
  {
    filter: (post) => post.authorId === "1",
    order: (a, b) => b.createdAt.localeCompare(a.createdAt),
  },
  async () => {
    const response = await fetch("/api/posts?author=1");
    return response.json();
  }
);

// Subscribe to records and status separately
query.$records.subscribe((posts) => {
  console.log("Posts:", posts);
});

query.$status.subscribe((status) => {
  console.log("Status:", status.status);
});

// Manually trigger a refetch
await query.refetch();

// Cleanup when done
query.dispose();

Cache key utilities

// Get the cache key for an entity ID
const key = repository.getCacheKey("users", { id: "1" }); // "1"

// Get the cache key from an entity object
const key = repository.getEntityKey("users", { id: "1", name: "Alice", email: "..." }); // "1"

RepositoryWriter - Optimistic writes

RepositoryWriter wraps a repository to apply optimistic mutations: it patches the local cache first, then fires the remote call, rolling the cache back if it fails. Operations on the same entity are serialised through a per-entity FIFO queue so rapid edits to one row reach the server in order.

Every method returns a promise that resolves once remote() lands and rejects (after rollback) if it throws. await it when you need a loading / error state, or ignore it for pure fire-and-forget optimism.

import { RepositoryWriter } from "entity-repository";

const writer = new RepositoryWriter(repository);

// Update: patch the cache now, PATCH the server, roll back on failure.
// Fire-and-forget — the cache reverts itself if the request fails.
writer.enqueueUpdate("users", { id: "1" }, {
  local: (prev) => ({ ...prev, name: "Alice v2" }),
  remote: async () => {
    await api.patch(`/users/1`, { name: "Alice v2" });
  },
});

// Or await it to drive a loading / error state:
try {
  await writer.enqueueDelete("users", { id: "1" }, {
    remote: async () => { await api.delete(`/users/1`); },
  });
} catch (err) {
  toast(err.message); // the cache was already rolled back
}

// Insert: non-optimistic — the cache is seeded from the server's response, so
// the caller doesn't wait on a realtime echo to see the new row.
const created = await writer.enqueueInsert("users", {
  remote: () => api.post(`/users`, { name: "Bob" }),
});

In a browser, the first writer installs a beforeunload guard that blocks tab close while any queue still has pending work.

API Reference

Repository

| Method | Description | |--------|-------------| | set(table, entity) | Store entity in cache, emits insert/update event (skipped when deep-equal to the cached record) | | get(table, id) | Get cached entity or null | | del(table, id) | Remove entity from cache, emits delete event | | fetch(table, id, fetcher) | Get cached or fetch, deduplicates concurrent requests | | getObservable(table, id) | BehaviorSubject for entity changes | | getEvents(table) | Subject emitting insert/update/delete events | | recordQuery(table, id, fetcher) | Create RecordQuery instance | | listQuery(table, options, fetcher) | Create ListQuery instance | | getCacheKey(table, id) | Get cache key string from entity ID | | getEntityKey(table, entity) | Get cache key string from entity object |

RecordQuery

Manages single-entity queries with status tracking.

| Property/Method | Description | |-----------------|-------------| | $state | BehaviorSubject<{ entity, status }> - subscribe to state changes | | fetch() | Manually trigger fetch, returns entity | | dispose() | Cleanup subscriptions |

ListQuery

Manages list queries with filter/sort and real-time updates.

| Property/Method | Description | |-----------------|-------------| | $records | BehaviorSubject<Entity[]> - subscribe to record list | | $status | BehaviorSubject<{ status, error? }> - subscribe to status | | refetch() | Manually trigger refetch, returns records | | dispose() | Cleanup subscriptions |

RepositoryWriter

Optimistic write wrapper around a repository.

| Method | Description | |--------|-------------| | enqueueUpdate(table, id, op) | Optimistically patch the cache, then run op.remote(); roll back on failure. Returns Promise<void> | | enqueueDelete(table, id, op) | Optimistically remove from the cache, then run op.remote(); restore on failure. Returns Promise<void> | | enqueueInsert(table, op) | Run op.remote(), seed the cache from its result; returns Promise of the inserted entity |

React Hooks

| Hook | Description | |------|-------------| | useRepository() | Access repository instance from context | | createEntityLoader(table, batchFetch, options?) | Build a batched, deduped per-id loader for one table (not a hook — call at module scope) | | useEntity(table, id, loader?) | Cache-first read of one entity; misses go through loader's batch | | useRepositoryQuery(table, id, fetcher) | Subscribe to single entity query with a per-callsite fetcher | | useRepositoryListQuery(table, param, options, fetcher) | Subscribe to filtered/sorted list query | | useSubscribedState(observable, initial) | Subscribe to any RxJS observable |

Types

// Entity configuration - maps tables to their ID field
type EntityConfig<Definitions> = {
  [Table in keyof Definitions]: { id: keyof Definitions[Table] & string };
};

// Entity ID tuple - picks only the ID field from entity
type EntityIdTuple<Definitions, Config, Table> =
  Pick<Definitions[Table], Config[Table]["id"]>;

// Entity event types - emitted on insert/update/delete
type EntityEvent<Entity> = {
  timestamp: Date;
} & (
  | { type: "insert"; new: Entity }
  | { type: "update"; old: Entity; new: Entity }
  | { type: "delete"; old: Entity }
);

// Single-entity query state
type RepositoryQuery<Entity> = {
  entity: Entity | null;
} & (
  | { status: "fetching" }
  | { status: "idle" }
  | { status: "error"; error: Error }
);

// List query options
type ListQueryOptions<Entity> = {
  filter?: (entity: Entity) => boolean;
  order?: (left: Entity, right: Entity) => number;
};

// List query status
type ListQueryStatus =
  | { status: "idle" }
  | { status: "fetching" }
  | { status: "error"; error: Error };

// List query state (returned by useRepositoryListQuery)
type ListQueryState<Entity> = {
  records: Entity[];
} & ListQueryStatus;

How It Works

Caching Strategy

The repository uses a simple Map-based cache keyed by table name and entity ID:

  1. Set: Stores entity and emits insert (new) or update (existing) event
  2. Get: Returns cached entity or null (synchronous)
  3. Fetch: Returns cached entity, or fetches and caches if not present
  4. Request deduplication: Concurrent fetches for the same entity share a single request

Reactivity

  • getObservable() returns a BehaviorSubject that emits when an entity changes
  • getEvents() returns a Subject that emits insert/update/delete events for a table
  • ListQuery subscribes to table events and automatically updates its filtered list

React Integration

  • useRepositoryQuery creates a RecordQuery and subscribes to its state
  • useRepositoryListQuery creates a ListQuery and subscribes to records + status
  • Both hooks use JSON.stringify(id/param) for stable memoization keys

License

MIT