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.
- 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.nameand get{id: string; name: string}[]. Selectp.friends.nameand 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:
LinkedStorageroutes query objects to your configured store(s) that implementIQuadStore. - Automatic Data Validation: SHACL shapes can be synced to your store for schema-level validation, and enforced at runtime by stores that support it.
npm install @_linked/coreAfter cloning this repository, run:
npm install
npm run setupnpm run setup syncs docs/agents into local folders for agent tooling:
.claude/agents.agents/agents
@_linked/rdf-mem-store: in-memory RDF store that implementsIQuadStore.@_linked/react: React bindings for Linked queries and shapes.
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
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; }
}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);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.
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.
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 .
}
}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:boolean → boolean, xsd:integer → number, xsd:dateTime → Date. Nested traversals are grouped and deduplicated into nested result objects.
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 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 moduleslinkedOntology(...): registers an ontology and (optionally) its data loaderregisterPackageExport(...): manually export something into the Linked package treeregisterPackageModule(...): lower-level module registrationgetPackageShape(...): resolve a Shape class by name to avoid circular imports
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 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> */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);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.
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.
- 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(...)andequals(...) and(...)/or(...)combinations- Set filtering with
some(...)/every(...)(and implicitsome) - 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
Result types are inferred from your Shape definitions and the selected paths. Examples below show abbreviated result shapes.
/* 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);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/* 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);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(),
);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'}),
);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')),
),
);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'))),
);const outer = await Person.select((p) => p.knows).where((p) =>
p.name.equals('Semmy'),
);/* Result: Array<{id: string; knows: number}> */
const count = await Person.select((p) => p.knows.size());/* Result: Array<{id: string; nameIsMoa: boolean; numFriends: number}> */
const custom = await Person.select((p) => ({
nameIsMoa: p.name.equals('Moa'),
numFriends: p.knows.size(),
}));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);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 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')),
);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),
]);/* Result: {id: string} & UpdatePartial<Person> */
const created = await Person.create({name: 'Alice'});Where UpdatePartial reflects the created properties.
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"}],
}
}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'}]);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, andnodeKind(widening is rejected at registration time). - If an override omits
minCount,maxCount, ornodeKind, inherited values are kept. - Current scope: compatibility checks for
datatype,class, andpatternare not enforced yet.
- Allow
preloadForto accept another query (not just a component). - Make and expose functions for auto syncing shapes to the graph.
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:
SparqlStorebase class — included in@_linked/core/sparql, extend it for any SPARQL endpoint@_linked/rdf-mem-store— in-memory RDF store
See CHANGELOG.md.