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. - Dynamic Query Building: Build queries programmatically with
QueryBuilder, compose field selections withFieldSet, and serialize/deserialize queries as JSON — for CMS dashboards, dynamic forms, and API-driven query construction. - 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 updated = await Person.update({
name: 'Alicia',
}).for({id: 'https://my.app/node1'});
/* 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
- Dynamic query building with
QueryBuilder - Composable field sets with
FieldSet - Mutation builders (
CreateBuilder,UpdateBuilder,DeleteBuilder) - Query and FieldSet JSON serialization / deserialization
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);/* Result: {id: string; name: string} */
const one = await Person.select((p) => p.name).for({id: 'https://my.app/node1'});
const missing = await Person.select((p) => p.name).for({id: 'https://my.app/missing'}); // 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. Chain .for(id) to target the entity:
/* Result: {id: string} & UpdatePartial<Person> */
const updated = await Person.update({name: 'Alicia'}).for({id: 'https://my.app/node1'});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({
knows: [{id: 'https://my.app/person2'}],
}).for({id: 'https://my.app/person1'});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({
knows: {
add: [{id: 'https://my.app/person2'}],
remove: [{id: 'https://my.app/person3'}],
},
}).for({id: 'https://my.app/person1'});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.
The DSL (Person.select(...)) is ideal when you know shapes at compile time. For apps that need to build queries at runtime — CMS dashboards, configurable reports, API endpoints that accept field selections — use QueryBuilder and FieldSet.
QueryBuilder provides a fluent, chainable API for constructing queries programmatically. It accepts a Shape class or a shape IRI string.
import {QueryBuilder} from '@_linked/core';
// From a Shape class
const query = QueryBuilder.from(Person)
.select(p => [p.name, p.knows])
.where(p => p.name.equals('Semmy'))
.limit(10);
// From a shape IRI string (when the Shape class isn't available at compile time)
const query = QueryBuilder.from('https://schema.org/Person')
.select(['name', 'knows'])
.where(p => p.name.equals('Semmy'));
// QueryBuilder is PromiseLike — await it directly
const results = await query;
// Or inspect the compiled IR without executing
const ir = query.build();Target specific entities:
// Single entity — result is unwrapped (not an array)
const person = await QueryBuilder.from(Person)
.for({id: 'https://my.app/person1'})
.select(p => p.name);
// Multiple entities
const people = await QueryBuilder.from(Person)
.forAll([{id: 'https://my.app/p1'}, {id: 'https://my.app/p2'}])
.select(p => p.name);Sorting, limiting, and single results:
const topFive = await QueryBuilder.from(Person)
.select(p => p.name)
.orderBy(p => p.name, 'ASC')
.limit(5);
const first = await QueryBuilder.from(Person)
.select(p => p.name)
.one();Select with a FieldSet:
const fields = FieldSet.for(Person, ['name', 'knows']);
const results = await QueryBuilder.from(Person).select(fields);FieldSet is an independent, reusable object that describes which fields to select from a shape. Create them, compose them, and feed them into queries.
Creating a FieldSet:
import {FieldSet} from '@_linked/core';
// From a Shape class with string field names
const fs = FieldSet.for(Person, ['name', 'knows']);
// From a Shape class with a type-safe callback
const fs = FieldSet.for(Person, p => [p.name, p.knows]);
// From a shape IRI string (when you only have the shape's IRI)
const fs = FieldSet.for('https://schema.org/Person', ['name', 'knows']);
// Select all decorated properties
const allFields = FieldSet.all(Person);
// Select all properties with depth (includes nested shapes)
const deep = FieldSet.all(Person, {depth: 2});Nested fields:
// Dot-separated paths for nested properties
const fs = FieldSet.for(Person, ['name', 'knows.name']);
// Object form for nested sub-selections
const fs = FieldSet.for(Person, [{knows: ['name', 'hobby']}]);Composing FieldSets:
const base = FieldSet.for(Person, ['name']);
// Add fields
const extended = base.add(['knows', 'birthDate']);
// Remove fields
const minimal = extended.remove(['birthDate']);
// Pick specific fields
const picked = extended.pick(['name', 'knows']);
// Merge multiple FieldSets
const merged = FieldSet.merge([fieldSet1, fieldSet2]);Inspecting a FieldSet:
const fs = FieldSet.for(Person, ['name', 'knows']);
fs.labels(); // ['name', 'knows']
fs.paths(); // [PropertyPath, PropertyPath]Use cases:
// Dynamically selected fields from a UI
const fields = FieldSet.for(Person, userSelectedFields);
const results = await QueryBuilder.from(Person).select(fields);
// API gateway: accept fields as query parameters
const fields = FieldSet.for(Person, req.query.fields.split(','));
const results = await QueryBuilder.from(Person).select(fields);
// Component composition: merge field sets from child components
const merged = FieldSet.merge([headerFields, sidebarFields, contentFields]);
const results = await QueryBuilder.from(Person).select(merged);
// Progressive loading: start minimal, add detail on demand
const summary = FieldSet.for(Person, ['name']);
const detail = summary.add(['email', 'knows', 'birthDate']);The mutation builders are the programmatic equivalent of Person.create(...), Person.update(...), and Person.delete(...). They accept Shape classes or shape IRI strings.
import {CreateBuilder, UpdateBuilder, DeleteBuilder} from '@_linked/core';
// Create — equivalent to Person.create({name: 'Alice'})
const created = await CreateBuilder.from(Person)
.set({name: 'Alice'})
.withId('https://my.app/alice');
// Update — equivalent to Person.update({name: 'Alicia'}).for({id: '...'})
const updated = await UpdateBuilder.from(Person)
.for({id: 'https://my.app/alice'})
.set({name: 'Alicia'});
// Delete — equivalent to Person.delete({id: '...'})
const deleted = await DeleteBuilder.from(Person).for({id: 'https://my.app/alice'});
// All builders are PromiseLike — await them or call .build() for the IR
const ir = CreateBuilder.from(Person).set({name: 'Alice'}).build();Queries and FieldSets can be serialized to JSON and reconstructed — useful for saving query configurations, sending them over the wire, or building query editor UIs.
// Serialize a QueryBuilder
const query = QueryBuilder.from(Person)
.select(p => [p.name, p.knows])
.where(p => p.name.equals('Semmy'));
const json = query.toJSON();
// json is a plain object — store it, send it, etc.
// Reconstruct from JSON
const restored = QueryBuilder.fromJSON(json);
const results = await restored;
// FieldSet serialization works the same way
const fs = FieldSet.for(Person, ['name', 'knows']);
const fsJson = fs.toJSON();
const restoredFs = FieldSet.fromJSON(fsJson);Example JSON output for QueryBuilder.from(Person).select(p => p.name).toJSON():
{
"shape": "https://schema.org/Person",
"fields": [{"path": "name"}]
}- 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.