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

@silasdevs/core

v0.3.0

Published

Normalized, reactive data cache for multi-entity REST APIs with schema-driven classification and property-level React bindings.

Readme

@silasdevs/core

Normalized, reactive data cache for REST APIs.

Feed multi-entity server responses into store.classify() and silas routes each record to the correct table using schema-driven resolution — by response key name, by a discriminator field on each record, or both simultaneously. Records are cached in an in-memory normalized store with version guards, soft-delete support, and cursor-based paginated views. React hooks provide property-level granular re-renders via ES6 Proxy tracking — components only update when the specific fields they read change.

Designed for backends that return heterogeneous payloads where records from different entity types arrive in a single response and need to be normalized, cached, and rendered with minimal overhead.

Features

  • Proxy-based reactivity — wrap any object with proxify() and subscribe to changes.
  • Property-level tracking — components only re-render when the specific properties they read change, not on every mutation.
  • Scoped trackers — each subscription owns an isolated Tracker, safe for concurrent renders, nesting, and aborted renders.
  • Two-layer granular filteringuseProxy syncs tracked properties into the subscription system via setTrackedProps, enabling the flush handler to skip irrelevant callbacks at the notification level, with snapshot comparison as a fallback for the first mutation.
  • Configurable batching — microtask (default), synchronous, or manual batch modes.
  • In-memory store — schema-driven tables with insert/update/delete lifecycle, version guards, and soft-delete support.
  • Flexible classification — feed raw server payloads into store.classify() to auto-route records. Supports name-based resolution (response key = table name) and prop-based resolution (resolverProp / resolverValue per table), both modes working simultaneously in the same schema.
  • Paginated collections — cursor-based, bidirectional windowed views over store tables with reactive state tracking.
  • React hooksuseProxy, useRecord, useCollection, usePaginatedCollection, useQuery, useMutation built on useSyncExternalStore.
  • Legacy compatibility — drop-in replacement for the WeeiiWebSDK obs.js API.

Installation

npm install @silasdevs/core

React hooks require React 18+:

npm install react@^18

Quick Start

Core — Reactive Proxy

import { proxify, subscribe, batch } from '@silasdevs/core';

const user = proxify({ name: 'Alice', age: 30 });

subscribe(user, () => {
  console.log('User changed:', user.name, user.age);
});

// Single change — notifies after microtask.
user.name = 'Bob';

// Batched changes — single notification.
batch(() => {
  user.name = 'Charlie';
  user.age = 31;
});

Property-Level Tracking

Every subscription has a track(fn) method. Property reads inside fn are recorded so the subscription only fires when those specific properties change.

import { proxify, subscribe } from '@silasdevs/core';

const user = proxify({ name: 'Alice', email: '[email protected]', age: 30 });

const sub = subscribe(user, () => {
  console.log('Name changed!');
});

// Tell the subscription which properties we care about.
sub.track(() => {
  console.log(user.name); // Records 'name'
});

user.email = '[email protected]'; // ✅ No notification — email is not tracked.
user.age = 31;               // ✅ No notification — age is not tracked.
user.name = 'Bob';           // 🔔 Notification fires — name IS tracked.

You can also set tracked properties directly without a tracking function:

sub.setTrackedProps(new Set(['name', 'age']));
// Only mutations to 'name' or 'age' will fire the callback.

Tracking is scoped per subscription — each sub has its own isolated tracker. Nested track() calls use save/restore, so concurrent or interleaved renders never contaminate each other:

const subA = subscribe(proxy, cbA);
const subB = subscribe(proxy, cbB);

subA.track(() => {
  void proxy.name;       // subA tracks 'name'

  subB.track(() => {
    void proxy.email;    // subB tracks 'email' — does NOT affect subA
  });

  void proxy.age;        // subA also tracks 'age'
});
// subA → { name, age },  subB → { email }

Deep Proxies

Pass { deep: true } to automatically proxify nested plain objects:

const state = proxify({
  user: { name: 'Alice', address: { city: 'NYC' } },
  settings: { theme: 'dark' },
}, { deep: true });

// Nested access returns proxified objects.
state.user.address.city = 'LA'; // Triggers notification on the root proxy.

Store — In-Memory Database

import { createStore, defineSchema } from '@silasdevs/core/store';

const store = createStore({
  schema: defineSchema({
    tables: {
      user: { key: 'id', version: 'version' },
      post: { key: 'id', softDelete: 'activo' },
    },
  }),
});

// Insert
store.upsert('user', { id: 1, name: 'Alice', version: 1 });

// Update (version must be ≥ existing)
store.upsert('user', { id: 1, name: 'Alice Updated', version: 2 });

// Soft delete
store.upsert('post', { id: 10, title: 'Draft', activo: false });

// Classify a server payload — auto-routes to the correct tables.
store.classify({
  user: [
    { id: 2, name: 'Bob', version: 1 },
    { id: 3, name: 'Charlie', version: 1 },
  ],
  post: [
    { id: 11, title: 'Hello World', activo: true },
  ],
});

// Read
const alice = store.get('user', 1);
const allUsers = store.all('user');
const activeUsers = store.filter('user', u => u.activo !== false);

Schema — Table Resolution Strategies

Tables can be resolved in two ways, both usable simultaneously within the same schema:

Name-Based Resolution (default)

The response key maps directly to the table name. This is the default when no resolverProp is set.

const schema = defineSchema({
  tables: {
    user: { key: 'id' },
    post: { key: 'id', version: 'updated_at' },
  },
});

// Response key "user" → table "user", key "post" → table "post".
store.classify({
  user: [{ id: 1, name: 'Alice' }],
  post: [{ id: 10, title: 'Hello' }],
});

Prop-Based Resolution

When the server returns records from multiple entity types under a single response key (e.g., registro), use resolverProp and resolverValue to route each record individually based on a discriminator field.

const schema = defineSchema({
  tables: {
    entrega: { key: 'id', resolverProp: 'id_entidad', resolverValue: 31, softDelete: 'activo' },
    paquete: { key: 'id', resolverProp: 'id_entidad', resolverValue: 50, softDelete: 'activo' },
  },
});

// All records arrive under "registro", but each has an `id_entidad` field
// that determines which table it belongs to.
store.classify({
  registro: [
    { id: 1, id_entidad: 31, titulo: 'Envío A' },   // → entrega
    { id: 2, id_entidad: 50, peso: 2.5 },            // → paquete
    { id: 3, id_entidad: 31, titulo: 'Envío B' },    // → entrega
  ],
});

Any field name and value type (string, number, boolean) can be used as the resolver:

const schema = defineSchema({
  tables: {
    notifEmail: { key: 'id', resolverProp: 'type', resolverValue: 'email' },
    notifSms:   { key: 'id', resolverProp: 'type', resolverValue: 'sms' },
  },
});

Mixed Resolution

Name-based and prop-based tables can coexist. Prop-based resolution takes priority per record — if a record matches a resolverProp, it goes to that table regardless of the response key. Unmatched records fall back to name-based resolution.

const schema = defineSchema({
  tables: {
    deposito: { key: 'id' },                                                    // name-based
    entrega:  { key: 'id', resolverProp: 'id_entidad', resolverValue: 31 },     // prop-based
    paquete:  { key: 'id', resolverProp: 'id_entidad', resolverValue: 50 },     // prop-based
  },
});

store.classify({
  deposito: [{ id: 100, monto: 500 }],                        // → deposito (by name)
  registro: [
    { id: 1, id_entidad: 31, titulo: 'Envío' },               // → entrega (by prop)
    { id: 2, id_entidad: 50, peso: 1.2 },                     // → paquete (by prop)
  ],
});

Note: classify expects a flat Record<string, unknown> — each top-level key maps to an array of records. If the server response is nested (e.g., respuesta.datos), unwrap it before classifying: store.classify(respuesta.datos).

Paginated Collections

Cursor-based, bidirectional windowed views over store tables. Multiple independent paginated views can exist for the same table (e.g., two scroll panels).

import { createStore, defineSchema } from '@silasdevs/core/store';

const store = createStore({
  schema: defineSchema({
    tables: { entrega: { key: 'id' } },
  }),
});

// Create a paginated view.
const page = store.paginated<Entrega>('entrega');

// Add records from a descending fetch (e.g., newest first).
page.addPage(serverRecords, 'descending');

// Add a single new record at the top.
page.addRecord(newRecord, true);

// Read reactive state.
console.log(page.proxy.items);       // Proxified<Entrega>[]
console.log(page.proxy.count);       // number
console.log(page.proxy.cursorStart); // string | undefined
console.log(page.proxy.cursorEnd);   // string | undefined
console.log(page.proxy.hasMore);     // boolean

// Dispose when done.
store.disposePaginated(page);

Cursor utilities are also available as pure functions:

import { recalculateCursors, cursorFor } from '@silasdevs/core/store';

const boundaries = recalculateCursors(records, 'id');
const cursor = cursorFor(boundaries, 'descending'); // → boundaries.end

React — Hooks

useProxy — Property-Level Reactive Snapshots

useProxy returns a tracking snapshot. Only the properties your component reads during render are tracked — mutations to other properties are silently ignored.

import { useProxy } from '@silasdevs/core/react';

function UserCard({ user }: { user: Proxified<User> }) {
  const snap = useProxy(user);

  // This component reads `name` and `age`.
  // Mutations to `email`, `address`, etc. will NOT cause a re-render.
  return (
    <div>
      <h2>{snap.name}</h2>
      <p>Age: {snap.age}</p>
    </div>
  );
}

Under the hood:

  1. A shallow snapshot is cached (plain object, not a proxy).
  2. A tracking proxy records which properties your JSX reads.
  3. On the first mutation, snapshot comparison filters irrelevant changes. The tracked properties are then synced into the subscription system via setTrackedProps.
  4. On subsequent mutations, the flush handler pre-filters at the notification level — the callback is never even invoked for unrelated property changes.
  5. The tracking proxy identity is stable (useMemo), so passing it to children doesn't cause extra renders.

useRecord — Single Record from Store

import { useRecord } from '@silasdevs/core/react';

function UserProfile({ store, userId }: Props) {
  const user = useRecord<User>(store, 'user', userId);
  if (!user) return <p>Not found</p>;
  return <h1>{user.name}</h1>;
}

useCollection — Table Subscription

import { useCollection } from '@silasdevs/core/react';

function UserList({ store }: Props) {
  const { items, count } = useCollection<User>(store, 'user');
  return (
    <ul>
      {items.map(u => <li key={u.id}>{u.name}</li>)}
      <p>{count} users</p>
    </ul>
  );
}

usePaginatedCollection — Paginated Table View

import { usePaginatedCollection } from '@silasdevs/core/react';

function EntregaList({ store }: Props) {
  const {
    items, count, hasMore,
    cursorFor, addPage, clear, setHasMore,
  } = usePaginatedCollection<Entrega>(store, 'entrega');

  const fetchMore = (dir: CursorDirection) =>
    api.listar({ id_ultimo: cursorFor(dir), filas: 20 })
      .then(res => {
        addPage(res.datos.entrega, dir);
        if (res.datos.entrega.length < 20) setHasMore(false);
      });

  return <InfiniteTable items={items} onScroll={fetchMore} />;
}

Returns reactive state (items, count, cursorStart, cursorEnd, hasMore) with property-level tracking, plus stable action functions (addPage, addRecord, removeRecord, clear, setHasMore, cursorFor). The underlying PaginatedCollection is created on mount and disposed on unmount.

useQuery — Async Data Fetching

import { useQuery } from '@silasdevs/core/react';

function Users({ api }: Props) {
  const { data, isLoading, error, refetch } = useQuery(
    () => api.fetchUsers(),
    [/* deps */],
  );

  if (isLoading) return <Spinner />;
  if (error) return <Error error={error} />;
  return <UserList users={data} />;
}

useMutation — Imperative Async Mutations

import { useMutation } from '@silasdevs/core/react';

function CreateUser({ api }: Props) {
  const { mutate, isLoading } = useMutation(
    (input) => api.createUser(input),
    { onSuccess: () => alert('Created!') },
  );

  return (
    <button disabled={isLoading} onClick={() => mutate({ name: 'New User' })}>
      Create
    </button>
  );
}

Compat — Legacy WeeiiWebSDK API

Drop-in replacement for the obs.js module. Forces sync batch mode for backwards compatibility.

import Obs from '@silasdevs/core/compat';

const obj = Obs.proxify({ count: 0 });
const ticket = Obs.sub(obj, () => console.log('changed'), 'counter', false, false);
obj.count = 1; // logs "changed"
Obs.desub(ticket);

Subpath Exports

| Import | Description | |----------------------|------------------------------------------------| | @silasdevs/core | Core proxy + subscription + batching | | @silasdevs/core/store | Store, Schema, Collection, PaginatedCollection, classify, cursor | | @silasdevs/core/react | React hooks | | @silasdevs/core/compat | Legacy obs.js compatibility |

Batch Modes

| Mode | Behavior | |-------------|-------------------------------------------------| | microtask | Default. Defers via queueMicrotask | | sync | Immediate notification after every mutation | | manual | Only notifies inside explicit batch() calls |

import { setBatchMode } from '@silasdevs/core';

setBatchMode('sync');       // Immediate notifications
setBatchMode('manual');     // Only explicit batch()
setBatchMode('microtask');  // Default — grouped by microtask

Architecture

Tracking System

The tracking system uses scoped trackers instead of global mutable state, with a two-layer approach in React:

Component render
  └─ useProxy(proxy)
       ├─ Returns a tracking proxy (stable identity via useMemo)
       ├─ Resets trackedRef at start of each render
       └─ Property reads during JSX → recorded in trackedRef
            │
            ▼
Proxy mutation (proxy.name = 'Bob')
  └─ markDirty(proxyId, 'name')
       └─ batch system → flush → handleFlush
            └─ For each subscription:
                 ├─ Has _trackedProps (set via setTrackedProps)?
                 │   ├─ Changed prop in tracked set? → fire callback
                 │   └─ Not in set? → skip entirely (pre-filter)
                 └─ No _trackedProps? → fire callback
                      └─ useProxy callback:
                           ├─ Calls setTrackedProps(tracked) → future flushes pre-filter
                           └─ Snapshot comparison (fallback for first mutation)
                                ├─ Relevant change? → onStoreChange → re-render
                                └─ Irrelevant?     → skip (no re-render)

Each Subscription owns a Tracker created via createTracker(). The track(fn) method activates the tracker during fn via a module-level _activeTracker variable with save/restore for nesting safety. The proxy's get trap calls recordAccess(prop), which delegates to _activeTracker if active, or no-ops if outside a tracking window.

Classification Pipeline

store.classify(payload)
  └─ classifyData(store, data)
       └─ For each key in data:
            └─ For each record in data[key]:
                 ├─ 1. Prop-based: schema.resolveByProp(record)
                 │      Match record[resolverProp] === table.resolverValue
                 │      ├─ Match found → route to that table
                 │      └─ No match → continue
                 ├─ 2. Name-based: schema.resolveByName(key)
                 │      ├─ Table registered with that name → route
                 │      └─ Not found → continue
                 └─ 3. Alphanumeric key fallback:
                        /^[a-z_][a-z0-9_]*$/i → ad-hoc table with key name

Proxy Virtual Properties

Every proxified object exposes virtual properties intercepted by the proxy handler:

| Property | Type | Description | |----------------|-----------|-------------------------------------| | __proxy_id | string | Unique identifier for the proxy | | __source | object | Direct access to the underlying target (bypasses proxy) | | __is_proxy | true | Guard for isProxy() checks |

Setting __source performs an atomic full replacement — all properties are updated in a single batch.

API Reference

Core

| Export | Description | |----------------------------|-----------------------------------------------------------------| | proxify(obj, opts?) | Create a reactive proxy. Options: deep, batch | | isProxy(obj) | Check if an object is a Silas proxy | | subscribe(p, cb, opts?) | Subscribe to changes; returns Subscription | | unsubscribe(id) | Unsubscribe by ticket ID | | batch(fn) | Group mutations for a single notification | | setBatchMode(mode) | Set global batch mode: microtask, sync, or manual |

Subscription Object

The object returned by subscribe():

| Property / Method | Type | Description | |----------------------|------------------------------------------|--------------------------------------------------| | ticket | string | Unique subscription ID | | target | Proxified<T> | The observed proxy | | once | boolean | Auto-unsubscribe after first notification | | observer | object \| null | Optional observer reference | | callback | SubscribeCallback<T> | The notification handler | | unsubscribe() | () => void | Cancel this subscription | | track(fn) | <R>(fn: () => R) => R | Record property reads for granular notification | | setTrackedProps(s) | (props: ReadonlySet<string>) => void | Directly set tracked properties |

Subscribe Options

| Option | Type | Default | Description | |-------------|------------------|---------|---------------------------------------| | once | boolean | false | Auto-unsubscribe after first notify | | immediate | boolean | false | Execute callback immediately on subscribe | | observer | object \| null | null | Reference to subscribing object (debug) |

Store

| Export | Description | |-------------------------------|---------------------------------------------| | createStore(opts) | Create a new Store instance | | defineSchema(config) | Define a table schema | | classifyData(store, data) | Classify a raw payload into a store | | Collection | Observable collection class for a table | | PaginatedCollection | Cursor-based paginated view class | | recalculateCursors(recs, k) | Pure function: compute start/end cursors | | cursorFor(bounds, dir) | Pure function: get cursor for a direction | | ChangeType | Enum: NONE, INSERT, UPDATE, DELETE |

Schema Configuration

| TableConfig Property | Type | Default | Description | |------------------------|-------------------------------|--------------|---------------------------------------| | key | string | 'id' | Primary key field | | version | string | undefined | Version field for optimistic concurrency | | softDelete | string \| false | false | Field name; falsy value = deleted | | name | string | table key | External name in server responses | | resolverProp | string | undefined | Record property for prop-based routing | | resolverValue | string \| number \| boolean | undefined | Value to match against resolverProp |

resolverProp and resolverValue must be set together. Tables with resolverProp are excluded from name-based resolution.

Store Instance

| Method | Description | |--------------------------------|---------------------------------------------------| | store.get(table, id) | Get a single proxified record | | store.all(table) | Get all records in a table | | store.filter(table, fn) | Filter records with a predicate | | store.find(table, fn) | Find first record matching a predicate | | store.count(table) | Count records in a table | | store.upsert(table, data) | Insert or update a record | | store.remove(table, id) | Remove a record by ID | | store.classify(payload) | Auto-route a flat payload to tables by schema | | store.collection(table) | Get/create the reactive collection for a table | | store.paginated(table) | Create a new paginated view over a table | | store.disposePaginated(pc) | Dispose a paginated view | | store.clear(table?) | Clear one table or all tables |

React Hooks

| Hook | Description | |-------------------------------------|----------------------------------------------------------| | useProxy(proxy) | Property-tracked reactive snapshot | | useRecord(store, table, id) | Single record from store; re-renders on changes | | useCollection(store, table) | Subscribe to table collection (items + count) | | usePaginatedCollection(store, t) | Paginated view with cursor actions and reactive state | | useQuery(fn, deps, opts?) | Async fetching with isLoading, error, data | | useMutation(fn, opts?) | Imperative async mutation with loading state |

Types

| Type | Description | |-------------------------------|------------------------------------------------------| | Proxified<T> | A proxified object: T & ProxyMeta | | ProxyMeta | Virtual properties: __proxy_id, __source, __is_proxy | | ProxifyOptions | Options for proxify(): deep, batch | | Tracker | Scoped property tracker: record(), props(), reset() | | Subscription<T> | Live subscription with track(), setTrackedProps(), and unsubscribe() | | SubscribeCallback<T> | (value, subscription) => boolean \| void | | SubscribeOptions | Options for subscribe(): once, immediate, observer | | BatchMode | 'microtask' \| 'sync' \| 'manual' | | TableConfig | Per-table schema configuration | | SchemaConfig | Full schema definition ({ tables }) | | ResolvedTable | Normalized internal table config returned by schema | | CollectionState<T> | { items, count } for observable collections | | PaginatedState<T> | { items, count, cursorStart, cursorEnd, hasMore } | | CursorDirection | 'ascending' \| 'descending' | | CursorBoundaries | { start, end } boundary values | | ChangeType | Enum: NONE, INSERT, UPDATE, DELETE | | ChangeRecord<T> | { type, record, previous? } | | ClassifyResult | { changes, summary, tables } | | UseQueryResult<T> | { data, isLoading, error, refetch } | | UseMutationResult<T, V> | { mutate, mutateAsync, data, isLoading, error, reset } | | UsePaginatedCollectionResult<T> | Full return type of usePaginatedCollection |

Contributing

Development

npm ci              # Install dependencies
npm run typecheck   # Type-check with tsc
npm run lint        # Lint with ESLint
npm run test        # Run tests with vitest
npm run build       # Build with tsup

Adding a changeset

This project uses Changesets for versioning and changelog generation. When your PR includes a user-facing change, add a changeset:

npm run changeset

Follow the prompts to select the bump type (patch, minor, major) and describe the change. This creates a markdown file in .changeset/ — commit it with your PR.

Release flow

  1. PRs with changesets merge into main.
  2. The release workflow detects pending changesets and opens (or updates) a Version PR that bumps package.json, updates CHANGELOG.md, and removes consumed changeset files.
  3. When the Version PR is merged, the release workflow publishes to npm and creates a GitHub release with the corresponding git tag.

Manual setup required (repo admin)

  • Branch protection: Require the CI workflow to pass before merging to main.
  • npm token: Add an npm automation token as the NPM_TOKEN repository secret (Settings → Secrets → Actions).

License

MIT © Silas