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

arkiv-graph

v0.3.1

Published

Interactive graphs and tables for Arkiv entities, with scoped queries and optional wallet-signed Lifetime Extension.

Readme

arkiv-graph

Turn your Arkiv database into an interactive graph.

These packages are intended for testnet use.

Registry availability: run npm view [email protected] version before installation. If this release has not reached npm yet, use the published 0.3.0 guide. A local checkout is not proof of publication.

Version 0.3.1 targets @arkiv-network/sdk 0.8.0, viem 2.56.3, Node.js 22.22.3, and Tiramisu testnet (7738577). SDK 0.6/0.7 clients are not supported by fetchArkivGraph in this release. Legacy plain entity arrays remain accepted by buildGraph.

Nodes are your entities. Edges are the relationships you define (Arkiv has no joins — you declare how entities relate). References to other chains show up as external nodes, drawn purely from what your entities already store — arkiv-graph never reads those chains.

The existing hosted sample still uses the retired Braga network; it is not the Tiramisu sample described here. Run the sample locally. Release and verification status are recorded in verification.md.


Install

Check the exact release, then install it from the npm registry:

npm view [email protected] version
npm i [email protected]
# peer deps for the React component:
npm i [email protected] [email protected]
# required SDK/type peers (network access only occurs in fetchArkivGraph):
npm i @arkiv-network/[email protected] [email protected]

The core entry does not import React at runtime, but this combined package also installs the graph renderer's dependencies. A core-only install is not React-free. For local package development, run pnpm install --frozen-lockfile and pnpm build:lib from the repository root. Create an output directory, then run npm pack ./packages/arkiv-graph --pack-destination <absolute-existing-output-directory>. Installing that tarball tests a local artifact; it does not verify the npm release.

Minimal example (no network or credentials)

Save as example.mjs, then run node example.mjs:

import { buildGraph, buildTables } from "arkiv-graph";
const entities = [
  { key: "user-alice", attributes: [{ key: "entityType", value: "user" }, { key: "handle", value: "alice" }] },
  { key: "post-1", attributes: [{ key: "entityType", value: "post" }, { key: "authorHandle", value: "alice" }] },
];
const links = [{ type: "reference", attribute: "authorHandle", targetAttribute: "handle", targetType: "user" }];
const graph = buildGraph(entities, { links, arkivExplorer: "" });
const tables = buildTables(graph, entities, { links });
console.log(graph.nodes.length, graph.edges.length, tables.tables.length);

Expected: 2 1 2. These are local example objects, not on-chain entities. Use the reproducible read to query real Tiramisu data. For React:

"use client";
import { ArkivGraph, ArkivTables } from "arkiv-graph/react";
import type { Graph, TablesModel } from "arkiv-graph";
export function AppData({ graph, tables }: { graph: Graph; tables: TablesModel }) {
  return <><ArkivGraph data={graph} height={400} /><ArkivTables graph={graph} model={tables} /></>;
}

Pass the graph and tables built from the same data. The components and the sample's surrounding workflow use English labels.


How it works

arkiv-graph has two layers:

  1. Core (framework-agnostic, no UI imports)buildGraph(entities, options) turns a flat list of Arkiv entities into { nodes, edges }. buildTables(graph, entities) turns the same data into relational-style tables. fetchArkivGraph(options) queries Arkiv (paginated, with block timing for Entity Expiration) and builds the graph in one call.
  2. React (arkiv-graph/react)<ArkivGraph data={graph} /> (force-directed canvas) and <ArkivTables model={tables} graph={graph} /> (a Supabase-like data browser). Both load client-side, so they're safe to import in Next.js / SSR apps.

Two views of the same data

import { fetchArkivGraph, buildTables } from "arkiv-graph";
import { ArkivGraph, ArkivTables } from "arkiv-graph/react";

const { graph, entities, blockTiming } = await fetchArkivGraph({ project, createdBy, links });
const tables = buildTables(graph, entities, {
  links, ...(blockTiming ? { blockTiming } : {}),
});

// pick one:
<ArkivGraph data={graph} />                       // force-directed graph
<ArkivTables model={tables} graph={graph} />      // tables: one per entityType, with FK chips

<ArkivTables> renders one collection table per entityType (columns = your attributes, plus owner/Entity Expiration), a junction table for each join relationship (the like/follow rows themselves, for debugging), foreign-key chips that link related rows (coloured to match the graph's edges), client-side sort of the loaded rows, and a schema tab that lists your link rules and flags issues (unresolved references, zero-match rules, soon-to-expire rows). It's a data browser, not SQL — Arkiv has no joins, foreign keys, or migrations.

Link rules — you declare the relationships

Arkiv has no foreign keys. A relationship exists only as a shared attribute value or an attribute holding another entity's key/id. Link rules tell arkiv-graph how to read yours:

| Rule | What it does | | --- | --- | | reference | An attribute points at another entity. By key, or by a stable business id via targetAttribute (e.g. authorHandleuser.handle). | | join | A join entity (e.g. a like or follow row) is collapsed into an edge between the two nodes it connects. Resolve endpoints by key or by stable id (sourceMatchAttr / targetMatchAttr). | | shared | Entities with the same value for an attribute connect — through a hub node by default (avoids hairballs). | | tag | One tag node per distinct value; entities carrying it connect to it. | | owner | Connect each entity to a wallet node for its owner (or creator). |

import type { LinkRule } from "arkiv-graph";
const links: LinkRule[] = [
  // post.authorHandle → the user whose handle matches
  { type: "reference", attribute: "authorHandle", targetAttribute: "handle", targetType: "user", label: "by" },
  // a `like` entity (byHandle → postId) becomes a user→post edge; the like node disappears
  { type: "join", entityType: "like", sourceAttr: "byHandle", targetAttr: "postId",
    sourceMatchAttr: "handle", targetMatchAttr: "postId", label: "likes" },
  // group anything sharing a topic
  { type: "shared", attribute: "topic" },
];

External chains — the dots reaching out

If an entity stores a reference to another chain, arkiv-graph draws it as an external node. Detection is by attribute name (not by scanning every 0x value, so your own entity keys and internal references are never mistaken for foreign txs):

  • *ChainId / sourceChainId → a chain node (Ethereum, Base, Optimism, Arbitrum, Scroll, … see CHAIN_REGISTRY).
  • *Contract / contractAddress → a contract node under that chain.
  • *Tx / txHash → a tx node, with a link to that chain's explorer.
// an entity with these attributes…
[{ key: "mintChainId", value: 8453 }, { key: "mintTx", value: "0x…" }]
// …produces a "Base" node + a tx node linked to basescan.org. No RPC to Base is ever made.

Customize via the external option (chainIdKeys, contractKeys, txKeys, registry, nativeChainIds).

Pointing at a different Arkiv network (plug-and-play)

Arkiv testnets rotate. Tiramisu is the default; the library is not limited to one network — pass the chain you want and RPC, explorer links, and the "native" chain id for external detection all follow:

import { tiramisu } from "@arkiv-network/sdk/chains";
import { defineArkivNetwork, fetchArkivGraph } from "arkiv-graph";

// today: the SDK's bundled chain
await fetchArkivGraph({ chain: tiramisu, project, createdBy, links });

// next testnet, no code change — build it from config…
const next = defineArkivNetwork(tiramisu, {
  chainId: 12345,
  rpcUrl: "https://<new-testnet>/rpc",
  explorerUrl: "https://explorer.<new-testnet>",
});
await fetchArkivGraph({ chain: next, project, createdBy, links });

// …or, when the SDK ships the new network as its own export, just pass that:
// import { newnet } from "@arkiv-network/sdk/chains";
// await fetchArkivGraph({ chain: newnet, ... });

defineArkivNetwork(base, overrides) clones the base chain's Arkiv protocol internals (viem formatters/fees/serializers) and overrides only the identity, for networks using the same SDK protocol. This does not migrate between incompatible protocol generations. If you pass a client instead of a chain, also pass explorerUrl and nativeChainId so links and external detection match your network. With no chain/client, it falls back to the SDK's bundled Tiramisu chain.


API

buildGraph(entities, options?) → Graph

entities: an array of Arkiv entities (SDK entities, or any object with key, attributes, payload/toJson). options:

| option | default | meaning | | --- | --- | --- | | links | [] | the link rules above | | external | enabled | external-chain detection config | | typeAttribute | "entityType" | attribute used as the node's type | | labelKey | auto | payload/attribute key to label nodes (falls back to name/title/handle/text) | | blockTiming | — | from getBlockTiming(), enables Entity Expiration/fade | | createPlaceholders | true | draw faint "ghost" nodes for references whose target wasn't fetched (so Entity Expiration/expiry doesn't look like a bug) | | arkivExplorer | Tiramisu | explorer base for entity links |

fetchArkivGraph(options) → { entities, graph, blockTiming, truncated }

All of buildGraph's options, plus query filters: project, attributes (eq map), createdBy, ownedBy (great for "connect wallet → see your graph"), limit, and either client (your Arkiv public client) or rpcUrl. Handles pagination and pulls block timing automatically.

<ArkivGraph data={graph} … />

| prop | default | | | --- | --- | --- | | data | — | the Graph from build/fetch | | height | 560 | px; width fills the container | | theme | ARKIV_THEME | colors (ArkivGraphTheme from arkiv-graph/react) | | onNodeClick | — | callback | | showLegend / showFilters / showSearch / showDetail | true | toggles | | fadeExpiring | true | dim nodes as Entity Expiration runs down | | animate | true | particle flow on join/external edges | | nodeColor | — | (node) => string override |

The core entry also exports computeTtl, formatTtl, CHAIN_REGISTRY, lookupChain, detectGroups, and core model/configuration types. React component, theme and mutation callback types come from arkiv-graph/react:

import type { ArkivGraphTheme } from "arkiv-graph/react";

Pass the same theme to ArkivGraph and ArkivTables. In 0.3.1, optional surface, colorScheme ("dark" or "light"), onAccent, danger, success and warning colors also style sticky headers, detail/action panels and native date inputs. Existing theme objects remain compatible. Set contrasting text, muted, relationship and accent colors as well as the background for light mode; see the sample theme configuration. The library does not change the host page's theme or persist preferences.

detectGroups(entity, config?, internalKeys?) accepts one normalized NormEntity, not a graph. config is ExternalConfig; internalKeys is Set<string>. These control external-reference grouping; prefer buildGraph's automatic grouping for normal integration.

computeTtl(expiresAtBlock, createdAtBlock, timing) takes two block numbers (number | undefined) and BlockTiming | undefined, returning { ttlSeconds?, ttlFraction?, expiresAt? }. expiresAt is estimated Unix seconds; ttlFraction is a 0–1 fraction. Missing or invalid timing returns an empty object. formatTtl(seconds | undefined) returns a duration, expired, or when unknown. formatExpiry(unixSeconds | undefined) formats an estimated date in the viewer's locale. Prefer buildGraph's automatic conversion for SDK entities with bigint blocks.


Notes & gotchas (Arkiv-specific)

  • One shared public DB. Always pass createdBy (or ownedBy) so you graph your data, not entities other wallets injected with your project value.
  • Entity Expiration is real. Entities expire; nodes fade and formatTtl shows "2d 3h" / "expired". Expiry is cost-efficiency, not a bug.
  • SSR-safe. The renderer (react-force-graph-2d) is loaded client-side; <ArkivGraph> ships with the "use client" directive and renders a placeholder on the server. In Next.js, import it inside a Client Component.
  • Next.js setup. react-force-graph-2d is ESM-only, so add it (and this package) to transpilePackages in next.config:
    // next.config.ts
    const nextConfig = { transpilePackages: ["arkiv-graph", "react-force-graph-2d"] };
  • Node.js 22.22.3 is the tested runtime; other major versions have not been verified for this release.
  • arkiv-graph never reads external chains — external nodes are built only from references your own entities store.

At scale & edge cases

The library is built to degrade gracefully across dataset sizes and shapes:

| Case | What happens | What to do | | --- | --- | --- | | Giant DB (thousands of entities) | fetchArkivGraph paginates up to limit (default 500; Arkiv page cap is 200) and returns truncated: true when it hits the cap. | Filter with attributes / createdBy / ownedBy, raise limit, and prefer the Tables view (<ArkivTables>) which is far cheaper than the force simulation. Large force graphs require browser-specific performance testing. Surface truncated in your UI. | | Tiny / empty DB | Builds an empty graph; <ArkivGraph>/<ArkivTables> render an empty state — no crash. | Nothing. | | Many external chains | Each cross-chain reference becomes an external node. CHAIN_REGISTRY ships explorer URLs and a free public RPC for the common mainnets + testnets (Ethereum, Base, Optimism, Arbitrum, Polygon, Scroll, zkSync, Linea, Zora, Blast, Gnosis, + Sepolias). | Unknown chains fall back to Chain <id>. External chains are not read by default; to opt into reading one, grab its RPC via lookupChain(id).rpc and build your own client. | | Untyped entities (missing the configured type attribute) | Grouped under (untyped) in tables; still rendered as nodes. | Set typeAttribute to your app's field; the Tiramisu sample uses entity_type. | | Expired / missing references | Rendered as faint ghost nodes (not dropped), so Entity Expiration/expiry never looks like a rendering bug. | Expected; NoEntityFoundError on a stale pointer is normal. | | Huge text payloads | Tables keep every cell on one line (ellipsis + full value on hover); the detail card shows the full payload. | Nothing. | | Heterogeneous attributes per row | Each table column is the union of that type's attributes; missing values render as . | Nothing. |

Sorting/pagination honesty: the tables sort the loaded rows client-side (Arkiv has no server-side ORDER BY). For globally-sorted huge tables, page with limit + your own ordering.

License

MIT © Arkiv DevRel

Reproducible read (no wallet connection or access key)

Use Node.js 22. From an empty directory:

npm init -y
npm install [email protected] @arkiv-network/[email protected] [email protected]

Save as read.mjs:

import { fetchArkivGraph, buildTables } from "arkiv-graph";
const links = [{ type: "reference", attribute: "author_handle", targetAttribute: "handle", targetType: "user" }];
const typeAttribute = "entity_type";
const result = await fetchArkivGraph({
  ownedBy: "0xa618A2736431f24C26F1C8Dac9CA00ECc845a1C6",
  project: "arkiv-graph-social-v2",
  links, typeAttribute,
});
const tables = buildTables(result.graph, result.entities, {
  links, typeAttribute,
  ...(result.blockTiming ? { blockTiming: result.blockTiming } : {}),
});
console.log({ entities: result.entities.length, nodes: result.graph.nodes.length,
  tables: tables.tables.map(table => table.type), truncated: result.truncated });

Run node read.mjs. Before the sample is created (or after expiration), expect { entities: 0, nodes: 0, tables: [], truncated: false }. Once it has been created and while its entities remain active, the types include user, post, comment, follow, and like. An empty result is valid if it has not been created or has expired. Replace the public address and project with your own to inspect your app.

To cross-check the public source independently, use the SDK with the same owner and project filters. Do not use getEntityCount() for this comparison: it counts the whole network. For a small sample:

import { createPublicClient } from "@arkiv-network/sdk";
import { tiramisu } from "@arkiv-network/sdk/chains";
import { eq } from "@arkiv-network/sdk/query";
import { http } from "viem";
const client = createPublicClient({ chain: tiramisu, transport: http() });
const query = client.select({ key: true })
  .ownedBy("0xa618A2736431f24C26F1C8Dac9CA00ECc845a1C6")
  .where(eq("project", "arkiv-graph-social-v2"));
let sourceCount = 0;
for await (const _entity of query) sourceCount++;
console.log(sourceCount);

Compare that count with result.entities.length when truncated is false. Graph node counts can differ because join entities collapse into edges and missing targets can produce placeholders. This count is not an access-control test.

For React, install react react-dom and use the /react entry shown above. In Next.js, place it in a client component and set transpilePackages: ["arkiv-graph", "react-force-graph-2d"] in next.config. The package includes ESM, CommonJS, and TypeScript declarations; the React entry preserves its "use client" directive. The core import is safe on the server.

Lifetime Extension with your wallet

The library does not hold keys or submit transactions. Pass onExtendEntity to ArkivTables to enable the action; omit it for a read-only view. The callback receives { entityKey, targetExpiresAt, row }, where the target is Unix seconds. Use the sample wallet implementation as the complete integration reference, including account/chain checks.

With SDK 0.8, an extension sets a new expiry, using wallet.extendEntity({ entityKey, expires: ExpirationTime.atBlock(targetBlock) }). It does not add a duration to the previous expiry. Read the entity and block timing again, verify ownership and a strictly later target, and round up to an absolute block. The SDK waits for the transaction receipt and returns the expiry from its event. Return a confirmed expiresAt and txUrl from your callback; refetch in onMutated. Dates are estimates based on block time, not exact clocks.

The sample restricts extensions to the owner even if another app creates entities with permissionless extension. The sample also implements the existing optional onDeleteEntity callback for single-entity deletion, restricted to the connected owner.

Delete an entity with your wallet

Pass onDeleteEntity to ArkivTables; omit it to hide Delete. Its confirmation passes { entityKey, row } to your handler. Use the sample's deleteEntityWithWallet implementation as the complete reference: re-read the entity, check ownership/liveness and active account/network, then call SDK0.8 wallet.deleteEntity({ entityKey }). Resolve only after a successful receipt, return { txUrl } and refetch in onMutated. A canceled dialog must not call the handler; a rejected or uncertain transaction must not report success.

Deletion removes one entity from active queries. Related entities remain and may show unresolved references. There is no automatic cascade or dashboard undo; historical copies may remain. Test deletion with a disposable entity you created for that purpose. The SDK's readonly creation flag protects payload/attributes; it does not prevent the owner from deleting the entity.

Prerequisites and troubleshooting

  • Reads: public Tiramisu RPC, a public owner/creator address and the app attributes. No private key, access key, wallet connection or funds are needed for reads.
  • Writes: MetaMask or another injected EIP-1193 wallet, Tiramisu selected, and test GLM from the Arkiv Hub faucet. The visitor reviews and signs each operation. Never paste a key into a web app.
  • Optional access keys: use the Hub when your own deployment needs a higher RPC allowance; keep credential-bearing RPC URLs server-side. The sample does not require them or accept arbitrary RPC URLs.
  • Set createdBy or ownedBy: supply a valid public wallet address. Project names alone do not identify a tenant on a public DB-Chain.
  • Empty result: check the network, owner (not just creator), exact attribute names and types, and whether the entities expired. A filter is not access control.
  • SDK 0.8 uses typed attribute maps and createdAt/expiresAt block metadata. Do not use removed buildQuery, withMetadata, or expiresIn APIs.
  • Tiramisu creation rejects uppercase custom attribute names even though SDK 0.8 accepts them during encoding. The sample uses entity_type, author_handle and post_id; pass typeAttribute: "entity_type" to both builders/fetch. The legacy entityType default and camelCase offline examples remain for compatibility with existing in-memory data; they are not creation examples.
  • Simulate the exact mutation against the configured RPC before wallet signing. Some wallets guess insufficient gas for a custom chain. The sample supplies the RPC estimate with a buffer, then rechecks the account and chain.
  • truncated: true: narrow the scope. The default limit is 500, maximum 5000; graph layout can become expensive before that ceiling. Pages use a fixed cursor.
  • Rejected signature, wrong owner, changed account or chain: no success should be shown. Reconnect/check the wallet and retry only after checking any transaction hash already returned. Never retry an uncertain submitted write blindly.
  • Rate limit or RPC failure: show an error and a manual retry. Do not poll in a loop.
  • Relationships by stable ids need explicit link rules; this is not a SQL engine. Public data is visible to everyone; this tool adds no private database or RLS.

Source, sample, and agent guides

Give your agent the applicable guide explicitly. Installing a package does not mean an agent will discover instructions inside node_modules.

Join entities with incomplete relationships

A join entity becomes an edge only when its endpoints resolve (or placeholders are enabled). Missing endpoints, self-references, or excluded endpoints leave the entity visible as an isolated node and a junction-table row with empty relationship cells and a warning. Join entities do not participate in owner, reference, shared, tag, or external-detection rules. Their owner, attributes and expiration remain available in the table and detail view. Repeated legacy attribute keys use the first value consistently in graph and table matching.