Skip to content

Semantu/linked

Repository files navigation

@_linked/core

Core Linked package for the query DSL, SHACL shape decorators/metadata, and package registration.

Linked core gives you a type-safe, schema-parameterized query language and SHACL-driven Shape classes for linked data. It compiles queries into a normalized Intermediate Representation (IR) that can be executed by any store.

Linked core offers

  • Schema-Parameterized Query DSL: TypeScript-embedded queries driven by your Shape definitions.
  • Fully Inferred Result Types: The TypeScript return type of every query is automatically inferred from the selected paths — no manual type annotations needed. Select p.name and get {id: string; name: string}[]. Select p.friends.name and get nested result types. This works for all operations: select, create, update, and delete.
  • Shape Classes (SHACL): TypeScript classes that generate SHACL shape metadata.
  • Object-Oriented Data Operations: Query, create, update, and delete data using the same Shape-based API.
  • Storage Routing: LinkedStorage routes query objects to your configured store(s) that implement IQuadStore.
  • Automatic Data Validation: SHACL shapes can be synced to your store for schema-level validation, and enforced at runtime by stores that support it.

Installation

npm install @_linked/core

Repository setup (contributors)

After cloning this repository, run:

npm install
npm run setup

npm run setup syncs docs/agents into local folders for agent tooling:

  • .claude/agents
  • .agents/agents

Related packages

  • @_linked/rdf-mem-store: in-memory RDF store that implements IQuadStore.
  • @_linked/react: React bindings for Linked queries and shapes.

Documentation

How Linked works — from shapes to query results

Linked turns TypeScript classes into a type-safe query pipeline. Here is the full flow, traced through a single example:

Shape class → DSL query → IR (AST) → Target query language → Execute → Map results

1. SHACL shapes from TypeScript classes

Shape classes use decorators to generate SHACL metadata. These shapes define the data model, drive the DSL's type safety, and can be synced to a store for runtime data validation.

@linkedShape
export class Person extends Shape {
  static targetClass = schema('Person');

  @literalProperty({path: schema('name'), maxCount: 1})
  get name(): string { return ''; }

  @objectProperty({path: schema('knows'), shape: Person})
  get friends(): ShapeSet<Person> { return null; }
}

2. Type-safe query DSL with inferred result types

The DSL uses these shape classes to provide compile-time checked queries. You cannot write a query that references a property not defined on the shape. The result type is fully inferred from the selected paths — no manual type annotations needed:

// TypeScript infers: Promise<{id: string; name: string}[]>
const result = await Person.select(p => p.name);

// TypeScript infers: Promise<{id: string; friends: {id: string; name: string}[]}[]>
const nested = await Person.select(p => p.friends.name);

3. SHACL-based Intermediate Representation (IR)

The DSL compiles to a backend-agnostic AST — the Intermediate Representation. This is the contract between the DSL and any store implementation.

{
  "kind": "select",
  "root": { "kind": "shape_scan", "shape": ".../Person", "alias": "a0" },
  "projection": [
    { "alias": "a1", "expression": { "kind": "property_expr", "sourceAlias": "a0", "property": ".../name" } }
  ],
  "resultMap": [{ "key": ".../name", "alias": "a1" }]
}

The IR uses full SHACL-derived URIs for shapes and properties. Any store that implements IQuadStore receives these IR objects and translates them into its native query language.

4. IR → SPARQL Algebra

For SPARQL-backed stores, the IR is converted into a formal SPARQL algebra — a tree of typed nodes aligned with the SPARQL 1.1 specification.

SparqlSelectPlan {
  projection: [?a0, ?a0_name]
  algebra: LeftJoin(
    BGP(?a0 rdf:type <Person>),
    BGP(?a0 <name> ?a0_name)       ← wrapped in OPTIONAL
  )
}

Properties are wrapped in LeftJoin (OPTIONAL) so missing values don't eliminate result rows.

5. SPARQL Algebra → SPARQL string

The algebra is a plain data structure — stores can inspect or optimize it before serialization (e.g., rewriting patterns, adding graph clauses, or pruning redundant joins).

The algebra tree is then serialized into a SPARQL query string with automatic PREFIX generation:

PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT DISTINCT ?a0 ?a0_name
WHERE {
  ?a0 rdf:type <.../Person> .
  OPTIONAL {
    ?a0 <.../name> ?a0_name .
  }
}

6. Execute and map results

The SPARQL endpoint returns JSON results, which are mapped back into typed result objects:

Endpoint returns:                        Mapped to:
┌──────────┬──────────┐                  ┌──────────────────────────────┐
│ a0       │ a0_name  │                  │ { id: ".../p1", name: "Semmy" } │
│ .../p1   │ "Semmy"  │        →         │ { id: ".../p2", name: "Moa"   } │
│ .../p2   │ "Moa"    │                  │ ...                          │
└──────────┴──────────┘                  └──────────────────────────────┘

Values are automatically coerced: xsd:booleanboolean, xsd:integernumber, xsd:dateTimeDate. Nested traversals are grouped and deduplicated into nested result objects.

The SparqlStore base class

SparqlStore handles this entire pipeline. Concrete stores only implement the transport:

import { SparqlStore } from '@_linked/core/sparql';

class MyStore extends SparqlStore {
  protected async executeSparqlSelect(sparql: string) {
    // Send SPARQL to your endpoint, return JSON results
  }
  protected async executeSparqlUpdate(sparql: string) {
    // Send SPARQL UPDATE to your endpoint
  }
}

See the SPARQL Algebra Layer docs for the full type reference, conversion algorithm, and store implementation guide.

Linked Package Setup

Linked packages expose shapes, utilities, and ontologies through a small package.ts file. This makes module exports discoverable across Linked modules and enables linked decorators.

Minimal package.ts

import {linkedPackage} from '@_linked/core/utils/Package';

export const {
  linkedShape,
  linkedUtil,
  linkedOntology,
  registerPackageExport,
  registerPackageModule,
  packageExports,
  getPackageShape,
} = linkedPackage('my-package-name');

Decorators and helpers

  • @linkedShape: registers a Shape class and generates SHACL shape metadata
  • @linkedUtil: exposes utilities to other Linked modules
  • linkedOntology(...): registers an ontology and (optionally) its data loader
  • registerPackageExport(...): manually export something into the Linked package tree
  • registerPackageModule(...): lower-level module registration
  • getPackageShape(...): resolve a Shape class by name to avoid circular imports

Shapes

Linked uses Shape classes to generate SHACL metadata. Paths, target classes, and node kinds are expressed as NodeReferenceValue objects: {id: string}.

import {Shape} from '@_linked/core';
import {ShapeSet} from '@_linked/core/collections/ShapeSet';
import {literalProperty, objectProperty} from '@_linked/core/shapes/SHACL';
import {createNameSpace} from '@_linked/core/utils/NameSpace';
import {linkedShape} from './package';

const schema = createNameSpace('https://schema.org/');
const PersonClass = schema('Person');
const name = schema('name');
const knows = schema('knows');

@linkedShape
export class Person extends Shape {
  static targetClass = PersonClass;

  @literalProperty({path: name, required: true, maxCount: 1})
  declare name: string;

  @objectProperty({path: knows, shape: Person})
  declare knows: ShapeSet<Person>;
}

Queries: Create, Select, Update, Delete

Queries are expressed with the same Shape classes and compile to a query object that a store executes. Use this section as a quick start. Detailed query variations are documented in Query examples below.

A few quick examples:

1) Select one field for all matching nodes

const names = await Person.select((p) => p.name);
/* names: {id: string; name: string}[] */

2) Select all decorated fields of nested related nodes

const allFriends = await Person.select((p) => p.knows.selectAll());
/* allFriends: {
  id?: string; 
  knows: {
    id?: string; 
    ...all decorated Person fields...
  }[]
	}[] */

3) Apply a simple mutation

const myNode = {id: 'https://my.app/node1'};
const updated = await Person.update(myNode, {
  name: 'Alicia',
});
/* updated: {id: string} & UpdatePartial<Person> */

Storage configuration

LinkedStorage is the routing helper (not an interface). It forwards query objects to a store that implements IQuadStore.

import {LinkedStorage} from '@_linked/core';
import {InMemoryStore} from '@_linked/rdf-mem-store';

LinkedStorage.setDefaultStore(new InMemoryStore());

You can also route specific shapes to specific stores:

LinkedStorage.setStoreForShapes(new InMemoryStore(), Person);

Automatic data validation

SHACL shapes are ideal for data validation. Linked generates SHACL shapes from your TypeScript Shape classes, which you can sync to your store for schema-level validation. When your store enforces those shapes at runtime, you get both schema validation and runtime enforcement for extra safety.

Schema-Parameterized Query DSL

The query DSL is schema-parameterized: you define your own SHACL shapes, and Linked exposes a type-safe, object-oriented query API for those shapes.

Query feature overview (core)

  • Basic selection (literals, objects, dates, booleans)
  • Target a specific subject by {id} or instance
  • Multiple paths and mixed results
  • Nested paths (deep selection)
  • Sub-queries on object/set properties
  • Filtering with where(...) and equals(...)
  • and(...) / or(...) combinations
  • Set filtering with some(...) / every(...) (and implicit some)
  • Outer where(...) chaining
  • Counting with .size()
  • Custom result formats (object mapping)
  • Type casting with .as(Shape)
  • Sorting, limiting, and .one()
  • Query context variables
  • Preloading (preloadFor) for component-like queries
  • Create / Update / Delete mutations

Query examples

Result types are inferred from your Shape definitions and the selected paths. Examples below show abbreviated result shapes.

Basic selection

/* names: {id: string; name: string}[] */
const names = await Person.select((p) => p.name);

/* friends: {
  id: string; 
  knows: { id: string }[]
}[] */
const friends = await Person.select((p) => p.knows);

const dates = await Person.select((p) => [p.birthDate, p.name]);
const flags = await Person.select((p) => p.isRealPerson);

Target a specific subject

const myNode = {id: 'https://my.app/node1'};
/* Result: {id: string; name: string} | null */
const one = await Person.select(myNode, (p) => p.name);
const missing = await Person.select({id: 'https://my.app/missing'}, (p) => p.name); // null

Multiple paths + nested paths

/* Result: Array<{id: string; name: string; knows: Array<{id: string}>; bestFriend: {id: string; name: string}}> */
const mixed = await Person.select((p) => [p.name, p.knows, p.bestFriend.name]);
const deep = await Person.select((p) => p.knows.bestFriend.name);

Sub-queries

const detailed = await Person.select((p) =>
  p.knows.select((f) => f.name),
);

const allPeople = await Person.selectAll();

const detailedAll = await Person.select((p) =>
  p.knows.selectAll(),
);

Where + equals

const filtered = await Person.select().where((p) => p.name.equals('Semmy'));
const byRef = await Person.select().where((p) =>
  p.bestFriend.equals({id: 'https://my.app/node3'}),
);

And / Or

const andQuery = await Person.select((p) =>
  p.knows.where((f) =>
    f.name.equals('Moa').and(f.hobby.equals('Jogging')),
  ),
);
const orQuery = await Person.select((p) =>
  p.knows.where((f) =>
    f.name.equals('Jinx').or(f.hobby.equals('Jogging')),
  ),
);

Set filtering (some/every)

const implicitSome = await Person.select().where((p) =>
  p.knows.name.equals('Moa'),
);
const explicitSome = await Person.select().where((p) =>
  p.knows.some((f) => f.name.equals('Moa')),
);
const every = await Person.select().where((p) =>
  p.knows.every((f) => f.name.equals('Moa').or(f.name.equals('Jinx'))),
);

Outer where chaining

const outer = await Person.select((p) => p.knows).where((p) =>
  p.name.equals('Semmy'),
);

Counting (size)

/* Result: Array<{id: string; knows: number}> */
const count = await Person.select((p) => p.knows.size());

Custom result formats

/* Result: Array<{id: string; nameIsMoa: boolean; numFriends: number}> */
const custom = await Person.select((p) => ({
  nameIsMoa: p.name.equals('Moa'),
  numFriends: p.knows.size(),
}));

Query As (type casting to a sub shape)

If person.pets returns an array of Pets. And Dog extends Pet. And you want to select properties of those pets that are dogs:

const guards = await Person.select((p) => p.pets.as(Dog).guardDogLevel);

Sorting, limiting, one

const sorted = await Person.select((p) => p.name).sortBy((p) => p.name, 'ASC');
const limited = await Person.select((p) => p.name).limit(1);
const single = await Person.select((p) => p.name).one();

Query context

Query context lets you inject request-scoped values (like the current user) into filters without threading them through every call.

setQueryContext('user', {id: 'https://my.app/user1'}, Person);
const ctx = await Person.select((p) => p.name).where((p) =>
  p.bestFriend.equals(getQueryContext('user')),
);

Preload

Preloading appends another query to the current query so the combined data is loaded in one round-trip. This is helpful when rendering a nested tree of components and loading all data at once.

const preloaded = await Person.select((p) => [
  p.hobby,
  p.bestFriend.preloadFor(ChildComponent),
]);

Create

/* Result: {id: string} & UpdatePartial<Person> */
const created = await Person.create({name: 'Alice'});

Where UpdatePartial reflects the created properties.

Update

Update will patch any property that you send as payload and leave the rest untouched.

/* Result: {id: string} & UpdatePartial<Person> */
const updated = await Person.update({id: 'https://my.app/node1'}, {name: 'Alicia'});

Returns:

{
  id:"https://my.app/node1",
  name:"Alicia"
}

Updating multi-value properties When updating a property that holds multiple values (one that returns an array in the results), you can either overwrite all the values with a new explicit array of values, or delete from/add to the current values.

To overwrite all values:

// Overwrite the full set of "knows" values.
const overwriteFriends = await Person.update({id: 'https://my.app/person1'}, {
  knows: [{id: 'https://my.app/person2'}],
});

The result will contain an object with updatedTo, to indicate that previous values were overwritten to this new set of values:

{
  id: "https://my.app/person1",
  knows: {
    updatedTo: [{id:"https://my.app/person2"}],
  }
}

To make incremental changes to the current set of values you can provide an object with add and/or remove keys:

// Add one value and remove one value without replacing the whole set.
const addRemoveFriends = await Person.update({id: 'https://my.app/person1'}, {
  knows: {
    add: [{id: 'https://my.app/person2'}],
    remove: [{id: 'https://my.app/person3'}],
  },
});

This returns an object with the added and removed items

{
  id: "https://my.app/person1",
  knows: {
    added?: [{id:"https://my.app/person2"},
    removed?: [{id:"https://my.app/person3"}],
  }
}

Delete

To delete a node entirely:

/* Result: {deleted: Array<{id: string}>, count: number} */
const deleted = await Person.delete({id: 'https://my.app/node1'});

Returns

{
  deleted:[
    {id:"https://my.app/node1"}
  ],
  count:1
}

To delete multiple nodes pass an array:

/* Result: {deleted: Array<{id: string}>, count: number} */
const deleted = await Person.delete([{id: 'https://my.app/node1'},{id: 'https://my.app/node2'}]);

Extending shapes

Shape classes can extend other shape classes. Subclasses inherit property shapes from their superclasses and may override them. This example assumes Person from the Shapes section above.

import {literalProperty} from '@_linked/core/shapes/SHACL';
import {createNameSpace} from '@_linked/core/utils/NameSpace';
import {linkedShape} from './package';

const schema = createNameSpace('https://schema.org/');
const EmployeeClass = schema('Employee');
const name = schema('name');
const employeeId = schema('employeeId');

@linkedShape
export class Employee extends Person {
  static targetClass = EmployeeClass;

  // Override inherited "name" with stricter constraints (still maxCount: 1)
  @literalProperty({path: name, required: true, minLength: 2, maxCount: 1})
  declare name: string;

  @literalProperty({path: employeeId, required: true, maxCount: 1})
  declare employeeId: string;
}

Override behavior:

  • NodeShape.getUniquePropertyShapes() returns one property shape per label, with subclass overrides taking precedence.
  • Overrides must be tighten-only for minCount, maxCount, and nodeKind (widening is rejected at registration time).
  • If an override omits minCount, maxCount, or nodeKind, inherited values are kept.
  • Current scope: compatibility checks for datatype, class, and pattern are not enforced yet.

TODO

  • Allow preloadFor to accept another query (not just a component).
  • Make and expose functions for auto syncing shapes to the graph.

Intermediate Representation (IR)

Every Linked query compiles to a plain, JSON-serializable JavaScript object — the Intermediate Representation. This IR is the contract between the DSL and any storage backend. A store receives these objects and translates them into its native query language (SPARQL, SQL, etc.).

For example, this DSL call:

const names = await Person.select((p) => p.name);

produces the following IR object, which is passed to your store's selectQuery() method:

{
  "kind": "select",
  "root": {"kind": "shape_scan", "shape": "https://schema.org/Person", "alias": "a0"},
  "patterns": [],
  "projection": [
    {
      "alias": "a1",
      "expression": {"kind": "property_expr", "sourceAlias": "a0", "property": "https://schema.org/name"}
    }
  ],
  "resultMap": [{"key": "name", "alias": "a1"}],
  "singleResult": false
}

All IR types are available from @_linked/core/queries/IntermediateRepresentation. See the full Intermediate Representation docs for the complete type reference, examples, and a store implementer guide.

Store packages:

  • SparqlStore base class — included in @_linked/core/sparql, extend it for any SPARQL endpoint
  • @_linked/rdf-mem-store — in-memory RDF store

Changelog

See CHANGELOG.md.

About

@_linked/core - Core library for Linked.js. Queries based on SHACL Shapes

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors