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

@issuegraph/store

v0.2.0

Published

A framework-free client store for an Issuegraph document, plus the data-source port a host plugs its tracker into. Renders edits optimistically, dispatches every mutation outward, and never re-evaluates the selection order until the write lands.

Readme

@issuegraph/store

A framework-free client store for an Issuegraph document, and the data-source port a host plugs its own tracker in through.

The package fetches nothing, authenticates nothing and persists nothing. It holds the document, renders edits optimistically, dispatches every one of them outward, and refuses to re-evaluate the selection order until a write has actually landed.

npm install @issuegraph/store

Apache-2.0. 0.x, and the API is unstable until 1.0 — a published package is a public commitment, and this one is not making it yet.


The five-minute version

import { createMemorySource, createStore } from '@issuegraph/store';

const store = createStore({
  source: createMemorySource({
    issues: [
      { ref: '1', title: 'Ship the thing', state: 'open' },
      { ref: '2', title: 'Build the thing', state: 'open' },
    ],
    edges: [],
  }),
  derive: myOrderDeriver, // see "The deriver" below
});

await store.hydrate();

const handle = store.propose({ op: 'create', kind: 'blocked-by', from: '1', to: '2' });
// The edge is already drawn, marked `pending-write`.
// The order has NOT moved, and reports `status: 'held'`.

await handle.settled;
// Now the order has re-derived, and `snapshot.lastChange` says what moved.

Read state with getSnapshot() and react to it with subscribe(listener) — the pair useSyncExternalStore wants, so a React host needs no adapter and a non-React host is not asked to pretend it is one.

Every array reachable from a snapshot is frozen — nested ones included, so a conflict record's upstream.edges and an edge's states are as protected as landed. They are the store's own state, so a .sort() on one would reorder that state with no dispatch and no notification; [...rows].sort() is the form to use. The store copies before freezing, so handing it an array never freezes an adapter out of its own storage. The elements are left as you made them — the store owns which edges and rows it holds, you own what each one is.


The data-source port

One interface, two methods. Everything else an adapter needs — a token, a retry policy, a rate limiter, a cache — stays on its own side of it.

interface DataSource {
  hydrate(): Promise<GraphDocument>;
  dispatch(mutation: Mutation): Promise<DispatchResult>;
}

dispatch answers with one of four outcomes:

| outcome | what it means | what the store does | |---|---|---| | applied | the edit landed; carries the whole resulting document | adopts it, re-derives the order, emits a change summary | | unchanged | the edit changed nothing; carries the whole document | adopts it, re-derives, emits no summary | | rejected | the write was refused; carries a reason | marks the edge failed and offers retry | | conflict | the document moved upstream mid-edit; carries the current one | marks the edge conflict and holds both versions |

Three contracts an adapter has to honour:

  • applied and unchanged both carry the authoritative full document — issues included — not a patch. A partial answer would need merge rules, and merge rules are where an optimistic store goes wrong. Both, because both are the same kind of claim about the document; they differ only in whether this edit caused the difference, which is what decides whether a change summary is emitted. unchanged matters more than it looks: an adapter often has nothing to apply precisely because your store is out of date — another client got there first — and a bare "nothing to do" would leave you without an edge that genuinely exists. An adapter that changed no issues returns the ones it holds.
  • The store dispatches once per mutation and never retries on its own. A retry is a user act, so nothing here assumes an idempotency you have not been given.
  • The store runs one authoritative operation at a time, queueing the rest, so dispatch is never re-entered and never overlaps a hydrate. Both answer with an unversioned authoritative document, and two of those in flight cannot be ordered by anything the store can observe — whichever answers second wins, and if that is the older one it silently rolls an edge back out. Rather than push a version onto every adapter, the store declines to create the situation. A queued edit still renders pending-write immediately; only the round trip waits, and rehydrate() resolves once it has had its turn.

Because of that last one, an edit reaches the adapter some time after it is proposed. createScriptedSource exposes whenPending(mutationId?) for exactly this — await the hand-off rather than guessing how many microtasks separate the two.

Two adapters ship with the package. createMemorySource holds a document in a variable and applies every edit — it is the reference implementation, and the one a demo runs on with no tracker at all. createScriptedSource settles only when told to, which is what lets a test observe the store mid-flight and induce a rejection or a conflict on purpose; write your own adapter's tests against it.


The deriver, and why you supply it

type OrderDeriver = (document: GraphDocument) => readonly OrderRow[];

Required, with no default. The selection order is its own concern with its own package, and a default here would be a second implementation of it — which is exactly the duplication this package family exists to remove.

The division of labour: the store owns when the order is recomputed; the deriver owns what it is.

An optional EdgeGuard sits beside it for refusals that need to see the graph — a blocked-by that would close a cycle:

type EdgeGuard = (context: { mutation; current; next }) => InvalidReason | undefined;

current is the newest document the source has answered with, which is the landed one except after a conflict: the conflict's upstream is newer than anything landed, so it is what the guard is shown until the next answer that lands replaces it. A verdict reached on the pre-conflict document would admit an edit that closes a loop on the source's own document (the edge the upstream added, with the one queued behind it), and an adapter applies what it is handed. Only the guard reads the newer copy: the order and the drawn edges keep reading landed, and so do the store's own structural refusals — a duplicate or a vanished edge is a question the source answers itself, with a document the store adopts, so letting that dispatch through is what repairs the store's copy.


The order can be trusted, structurally

The store keeps two edge sets:

  • landed — what the data source has confirmed. The only input to the deriver.
  • projected — landed plus every unsettled edit, each carrying its states. What a viewer draws.

Optimistic rendering is allowed; optimistic re-ordering is not. The rail's whole value is being what a picker will actually do, and an order reflecting an unlanded edit is a ranking that exists nowhere. Keeping the two sets apart makes that a property of the structure rather than a rule every reader has to remember.

While any edit is in flight, order.status is 'held' and the rows are the previous ones, unchanged — a stale-but-labelled order beats a half-computed one. Same principle if your deriver throws: the last order that was derived stands, and orderError says why it is stale.

Host callbacks that throw cannot break the store

Three of them — the deriver, the guard, and a subscriber — and one rule for all three, because they are all your code and the store already reads a throwing adapter as a rejection.

A guard that throws reached no verdict, and an unknown verdict is not permission to write: the edit is refused invalid with code guard-failed. A deriver that throws follows a write that already landed, so it cannot become a failed write; the order goes stale with orderError set. A subscriber that throws is isolated and rethrown asynchronously, so it still surfaces without taking the notification loop down.

None of them may escape into the dispatch queue, where they would strand every edit behind them with the order held for ever.

Edge states are overlays, not variants

An edge always keeps its kind. State is carried beside it, as a list, because the five states are orthogonal and combine:

selected · pending-write · invalid · failed · conflict

invalid is refused before any write — the adapter is never called. Two things produce it, split by what the answer needs to see:

  • the store itself, for what is visible in the edit — a self-edge, a reference the document does not hold, an exact duplicate, a retype to the kind the edge already has, a flip on a symmetric relationship, and a second reference on a single-valued field (cardinality: every relationship field but blocked-by holds one, §4.3 — the one writer rule of the format that needs no graph walk, so it is refused here rather than once per host);
  • your EdgeGuard, for anything needing the graph.

A failed write is marked, never reverted

The user's work stays on the canvas. retry re-dispatches it, and on a conflict you get the resolutions below. Nothing auto-merges, nothing auto-reverts, and nothing times out — discardMine is the only call that removes an optimistic edit, and a person has to make it.

Resolving a conflict

A conflict's upstream is for display, and is never adopted — it is the "view diff" half of the choice, and a reading of the past: a conflict can sit on screen for as long as a person takes to read it. The one other thing it is used for is the guard: it is the newest document the source has answered with, so an edit proposed or queued after the conflict is shown to your EdgeGuard against it rather than against the copy the source has said it no longer holds (see "The deriver" above). Shown, not adopted — the order does not move for it, and a retry is still re-dispatched unchanged.

The store ships three resolutions:

store.discardMine(id);     // drop the overlay; adopts nothing
store.retry(id);           // re-dispatch, unchanged, against the document as it stands
store.retryOnLatest(id);   // re-read the document, then re-dispatch, unchanged, against what it confirmed

discardMine and retry read the record and act on it with no await in between, so neither can be overtaken. retryOnLatest has a read in the middle, and everything that can happen during it — the read failing, the user discarding, the user pressing again — is a decision about user intent. The store settles all three by reserving the edit before the read: the record is pending from the call on, and a pending edit is one discardMine and a second press decline to touch, exactly as they do while any write is in flight. The read and the re-dispatch then run as one queued operation, so nothing can be queued between them:

  • the read fails — the record is restored exactly as it was, upstream and all, nothing is dispatched, and hydrationError says why. The conflict is still there to try again;
  • the read confirms a document that no longer admits the edit — it is refused invalid against that document and never dispatched;
  • otherwise the edit goes out against the confirmed document, with the guard judging it there.

That reservation is what the store used to lack, and why the composition once had to live in the host — issue #7 is the record of the three defects a hand-rolled version drew. The adapter is the authority on the current document; the store asks it rather than keeping a guess about how stale its own copy has become.

lastChange belongs to the edit that is current when it lands. Two edits proposed before the first settles means the first's summary would otherwise be written after the second began, and a host would show the previous edit's blast radius beside the current one.

Two smaller contracts worth knowing

  • Issue references are opaque. The format admits both 123 and owner/repo#123 (§4.2); normalising between them is the reader's job. This store compares references and never parses one, so an adapter must emit canonical identifiers.
  • Edge identity is derived, not assigned. It is a pure function of kind and endpoints, with the two symmetric kinds (§4.3.4, §4.3.7) sorting theirs — so A serialize-with B and B serialize-with A are one edge. That is what lets the store recognise the edge your adapter returns as the one it drew.

What this package does not do

Rendering of any kind, including the wording of the change summary — the counts ship as numbers so a host writes the sentence in its own language. Undo, multi-select batching, the first-pass review queue, audit findings and graph clustering all compose on top of it rather than living in it.