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.
- 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 filtering —
useProxysyncs tracked properties into the subscription system viasetTrackedProps, 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/resolverValueper table), both modes working simultaneously in the same schema. - Paginated collections — cursor-based, bidirectional windowed views over store tables with reactive state tracking.
- React hooks —
useProxy,useRecord,useCollection,usePaginatedCollection,useQuery,useMutationbuilt onuseSyncExternalStore. - Legacy compatibility — drop-in replacement for the WeeiiWebSDK
obs.jsAPI.
npm install @silasdevs/coreReact hooks require React 18+:
npm install react@^18import { 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;
});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: 'alice@test.com', 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 = 'new@test.com'; // ✅ 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 }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.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);Tables can be resolved in two ways, both usable simultaneously within the same schema:
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' }],
});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' },
},
});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:
classifyexpects a flatRecord<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).
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.enduseProxy 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:
- A shallow snapshot is cached (plain object, not a proxy).
- A tracking proxy records which properties your JSX reads.
- On the first mutation, snapshot comparison filters irrelevant changes. The tracked properties are then synced into the subscription system via
setTrackedProps. - On subsequent mutations, the flush handler pre-filters at the notification level — the callback is never even invoked for unrelated property changes.
- The tracking proxy identity is stable (
useMemo), so passing it to children doesn't cause extra renders.
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>;
}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>
);
}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.
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} />;
}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>
);
}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);| 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 |
| 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 microtaskThe 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.
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
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.
| 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 |
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 |
| 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) |
| 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 |
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.
| 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 |
| 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 |
| 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 |
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 tsupThis project uses Changesets for versioning and changelog generation. When your PR includes a user-facing change, add a changeset:
npm run changesetFollow 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.
- PRs with changesets merge into
main. - The release workflow detects pending changesets and opens (or updates) a Version PR that bumps
package.json, updatesCHANGELOG.md, and removes consumed changeset files. - When the Version PR is merged, the release workflow publishes to npm and creates a GitHub release with the corresponding git tag.
- Branch protection: Require the
CIworkflow to pass before merging tomain. - npm token: Add an npm automation token as the
NPM_TOKENrepository secret (Settings → Secrets → Actions).
MIT © Silas