Getting started with NextGraph Framework

This page introduces you to the ORM SDK for TypeScript. It supports both discrete CRDTs (Yjs and Automerge, JSON-based) and the RDF (graph) CRDT. If you are not familiar with CRDTs or RDF, you are advised to read the CRDT introduction.

The key difference: the RDF ORM can provide data from multiple documents at once (scoped by shapes and SPARQL), while the Discrete ORM subscribes to exactly one JSON document. Working with the data returned from both APIs is the same: You modify plain TypeScript objects and changes sync automatically.

Reference docs:


Packages Overview

PackagePurpose
@ng-org/webNextGraph engine API for web apps, session management
@ng-org/ormCore ORM, useShape(), useDiscrete(), subscriptions
@ng-org/shex-ormCLI to generate TypeScript types from SHEX schemas
@ng-org/alien-deepsignalsDeep reactive proxy layer with frontend-framework hooks

Frontend hooks are exported from sub-paths: @ng-org/orm/svelte, @ng-org/orm/svelte4, @ng-org/orm/vue, @ng-org/orm/react.


Installation

pnpm add @ng-org/orm @ng-org/web

# For RDF schemas (dev dependency)
pnpm add -D @ng-org/shex-orm

Initialization

Initialize NextGraph as early as possible in your app (e.g. in a layout or entry point). If the user is not logged-in, init() redirects to wallet authentication, and will reload the page afterwards.

import { ng, init as initNgWeb } from "@ng-org/web";
import { initNg } from "@ng-org/orm";

await initNgWeb(
  async (event) => {
    // Hand the engine interface and session to the ORM
    initNg(ng, event.session);
  },
  true,
  [],
);

A reusable helper (used by example apps) wraps this in a sessionPromise, see the expense tracker utils for a ready-made template.

Note: When you develop an app locally in Chrome, there are new restrictions for a public website including an iframe to localhost. And that’s what we do here in third party mode, when you are developing your app with vite on localhost. The first time you will load the page, a popup will appear, asking you: “nextgraph.eu wants to Look for and connect to any device on your local network”. You should click on “Allow”. If you get a gray screen, click on the recycle icon that is on the right side of the blue thin banner. Then you should be all good. If not, you have to go to chrome://flags/#local-network-access-check and select Disabled. This dev env issue has no impact on your production app deployed on your own domain, specially if you host your app with TLS.


The RDF (Graph) ORM

The RDF ORM gives you typed, reactive objects backed by RDF quads. You define your data shapes in SHEX (Shape Expressions), generate TypeScript types, and use useShape() in your components.

1. Define a SHEX schema

PREFIX ex: <did:ng:z:>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>

ex:ExpenseShape {
	  a [ex:Expense] ;                # Required type "Expense"
	  ex:title xsd:string ;           # Required string
	  ex:description xsd:string ? ;   # Optional string
	  ex:totalPrice xsd:float ;       # Required float
	  ex:amount xsd:integer ;         # Required integer
	  ex:dateOfPurchase xsd:date ;    # Date as ISO string
	  ex:isRecurring xsd:boolean ;    # Boolean flag
    ex:expenseCategory @ex:ExpenseCategoryShape * ;    # Set of nested objects
	  ex:paymentStatus [ex:Paid ex:Pending ex:Overdue] ; # Enum
}

ex:ExpenseCategoryShape EXTRA a {
  a [ex:ExpenseCategory] ;
  ex:categoryName xsd:string ;
}

Cardinality reference:

  • no suffix = required (exactly 1)
  • ? = optional
  • * = zero or more (will give you a Set<T>)
  • + = one or more (will give you a Set<T>)

See the full ShEx-ORM documentation for all supported types and annotations.

2. Generate TypeScript Shape Types

npx rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm

This produces three files per .shex file: type definitions, schemas, and shape types. Shape types are the objects that you pass to the ORM.

3. Use useShape() in a component

The hook creates a reactive, two-way bound Set of objects matching your shape. The syntax is the same across all supported frameworks:

import { useShape } from "@ng-org/orm/svelte"; // or /react, /vue, /svelte4
import { ExpenseShapeType } from "../shapes/orm/expenseShapes.shapeTypes";

// Subscribe to all Expense objects in the private store
const expenses = useShape(
  ExpenseShapeType,
  `did:ng:${session.private_store_id}`,
);

You can also pass a scope with specific graphs and subjects:

const expenses = useShape(ExpenseShapeType, {
  graphs: ["did:ng:o:g1", "did:ng:o:g2"],
  subjects: ["<s1 IRI>", "<s2 IRI>"],
});
// Type: DeepSignal<Set<Expense>>, usable as Set<Expense>

The Discrete (JSON-based) ORM

The discrete ORM works with Automerge or Yjs documents. No SHEX schema is needed: You subscribe to a single document by its NURI and get back a reactive JSON object or array.

1. Create or find your document

const { ng, session_id } = await sessionPromise;

// Search for an existing document by a class IRI you assign.
const ret = await ng.sparql_query(
  session_id,
  `SELECT ?id WHERE { GRAPH ?id { ?s a <did:ng:z:MyApp> } }`,
  undefined,
  undefined,
);
let documentId = ret?.results.bindings?.[0]?.id?.value;

// Or create a new one
if (!documentId) {
  documentId = await ng.doc_create(
    session_id,
    "Automerge",
    "data:json",
    "store",
    undefined,
  );
  await ng.sparql_update(
    session_id,
    `INSERT DATA { GRAPH <${documentId}> { <${documentId}> a <did:ng:z:MyApp> } }`,
    documentId,
  );
}

2. Use useDiscrete() in a component

import { useDiscrete } from "@ng-org/orm/svelte"; // or /react, /vue, /svelte4

const store = useDiscrete(documentId);
// Type: DeepSignal<DiscreteRootObject>, the full JSON-like document, reactively bound.

Modify the returned object like normal JavaScript, push to arrays, assign properties. Changes sync automatically. Note that objects in arrays receive a synthetic @id property that you can use to reference them elsewhere.


Working with Data

Both ORMs return DeepSignal objects, proxied plain objects that track reads (for fine-grained reactivity) and writes (for persistence and sync). You can treat DeepSignal objects as regular TypeScript objects.

Modifying Objects

expense.title = "Updated Title";
expense.totalPrice = 99.99;
expense.isRecurring = true;

Creating Objects (RDF)

expenses.add({
  "@graph": docNuri, // Required: document NURI
  "@type": "did:ng:z:Expense", // RDF type
  "@id": "", // Empty string = auto-generate
  title: "Groceries",
  totalPrice: 42.5,
  amount: 1,
  dateOfPurchase: new Date().toISOString(),
  paymentStatus: "did:ng:z:Paid",
  isRecurring: false,
  expenseCategory: new Set(),
});

Working with Sets (RDF)

Properties with cardinality * or + are reactive Set objects:

const groceriesCategory = {"@id": "", "@graph": expense["@graph"], "@type": "did:ng:z:ExpenseCategory", categoryName: "Groceries"}
expense.expenseCategory.add({category["@id"]});
expense.expenseCategory.delete(categoryIri);
expense.expenseCategory.has(categoryIri);

for (const category of expense.expenseCategory) { ... }

Relationships (RDF)

Link objects by storing the target’s @id IRI:

expense.expenseCategory.add({ "@id": category["@id"] });

If expense.expenseCategory was just of type Set<IRI>, you would call expense.expenseCategory.add(category['@id']). Semantically, the two are the same.

Note: Deleting a nested object from a parent only removes the link. The nested object’s data is preserved.

Using Subscriptions Outside of Components

For logic outside of components, use OrmSubscription or DiscreteOrmSubscription directly:

import { OrmSubscription } from "@ng-org/orm";

const sub = OrmSubscription.getOrCreate(DogShapeType, { graphs: [docNuri] });
await sub.readyPromise;

const dogs = sub.signalObject; // DeepSignal<Set<Dog>>
dogs.add({
  "@graph": docNuri,
  "@type": "did:ng:x:Dog",
  "@id": "",
  name: "Rex",
});

// Use in a component with useDeepSignal():
import { useDeepSignal } from "@ng-org/alien-deepsignals/svelte";
const reactiveDogs = useDeepSignal(sub.signalObject);

Subscriptions support transactions for batching changes: sub.beginTransaction() / sub.commitTransaction(). See documentation here

See the full ORM reference for more details on subscriptions, transactions, and the getObjects() / insertObject() functions.


DeepSignal Reactivity

Under the hood, ORM objects are wrapped with @ng-org/alien-deepsignals, a deep reactive proxy built on alien-signals. You might find them familiar to objects returned by Vue’s ref() or Svelte’s $state() functions. Features include:

  • Deep Proxies: Nested objects, arrays, and Sets are all proxied.
  • Patch stream: Mutations emit batched patches for syncing with the engine. Note that by default, patches are synced in a microtask after the current JS task finished. So for example, modifications to an object in the same function won’t hit the engine twice. You can finish a task, for example, by calling await Promise.resolve().
  • Framework hooks: useDeepSignal() for React, Vue, Svelte 5, and Svelte 4 (no Svelte 3 support).
  • Lazy: signals created only when a property is first read.

You don’t need to use @ng-org/alien-deepsignals directly. But it can be useful for working with shared state or when working with subscriptions outside of components.


Example Apps

Complete, runnable expense tracker apps are available for each framework and CRDT type:

All example apps follow the same structure and have the same expense tracker UI. You can use them as starter templates.


SPARQL Queries

Beyond the ORM, it can be handy to use raw SPARQL queries through the session:

const results = await ng.sparql_query(
  session_id,
  `SELECT ?expense ?title ?price WHERE {
    ?expense a                      <did:ng:z:Expense> ;
             <did:ng:z:title>      ?title ;
             <did:ng:z:totalPrice> ?price .
    FILTER (?price > 100)
  }`,
);

Further Reading