compact shapes & message broadcasting

main
Laurin Weger 2 weeks ago
parent bd472135e7
commit cc225bcde7
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 1724
      package-lock.json
  2. 2
      package.json
  3. 239
      src/ng-mock/js-land/connector/createSignalObjectForShape.ts
  4. 12
      src/ng-mock/js-land/frontendAdapters/react/useShape.ts
  5. 99
      src/ng-mock/wasm-land/requestShape.ts
  6. 255
      src/ng-mock/wasm-land/shapeHandler.ts
  7. 14
      src/ng-mock/wasm-land/sparql/README.md
  8. 149
      src/ng-mock/wasm-land/sparql/buildConstruct.ts
  9. 152
      src/ng-mock/wasm-land/sparql/buildSelect.ts
  10. 125
      src/ng-mock/wasm-land/sparql/common.ts
  11. 10
      src/ng-mock/wasm-land/updateShape.ts
  12. 77
      src/shapes/ldo/catShape.schema.compact.ts
  13. 104
      src/shapes/ldo/catShape.schema.ts
  14. 0
      src/shapes/ldo/catShape.shapeTypes.compact.ts
  15. 70
      src/shapes/ldo/personShape.schema.compact.ts
  16. 95
      src/shapes/ldo/personShape.schema.ts
  17. 0
      src/shapes/ldo/personShape.shapeTypes.compact.ts
  18. 114
      src/shapes/ldo/testShape.schema.compact.ts
  19. 150
      src/shapes/ldo/testShape.schema.ts
  20. 0
      src/shapes/ldo/testShape.shapeTypes.compact.ts

1724
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -29,12 +29,14 @@
"astro": "5.13.2",
"install": "^0.13.0",
"npm": "^11.5.2",
"prettier-eslint": "^16.4.2",
"react": "19.1.1",
"react-dom": "19.1.1",
"svelte": "5.38.2",
"vue": "3.5.19"
},
"devDependencies": {
"@ldo/traverser-shexj": "^1.0.0-alpha.28",
"@playwright/test": "^1.55.0",
"@types/node": "24.3.0",
"@types/react": "19.1.10",

@ -1,27 +1,38 @@
import updateShape from "src/ng-mock/wasm-land/updateShape";
import type { Connection, Diff, Scope, Shape } from "../types";
import requestShape from "src/ng-mock/wasm-land/requestShape";
import { applyDiff } from "./applyDiff";
import requestShape from "src/ng-mock/wasm-land/shapeHandler";
import { deepSignal, watch, batch } from "alien-deepsignals";
import type { DeepPatch, DeepSignalObject } from "alien-deepsignals";
import type { CompactShapeType } from "node_modules/@ldo/ldo/dist/types/ShapeType";
import type { LdoCompactBase } from "@ldo/ldo";
interface PoolEntry<T extends LdoCompactBase> {
connectionId: string;
key: string;
shape: CompactShapeType<T>;
shapeType: CompactShapeType<T>;
scopeKey: string;
signalObject: DeepSignalObject<T | {}>;
refCount: number;
stopListening: (() => void) | null;
registerCleanup?: (fn: () => void) => void;
connectionId?: string;
ready: Promise<string>; // resolves to connectionId
resolveReady: (id: string) => void;
suspendDeepWatcher: boolean;
ready: boolean;
// Promise that resolves once initial data has been applied.
readyPromise: Promise<void>;
resolveReady: () => void;
release: () => void;
}
const pool = new Map<string, PoolEntry<any>>();
interface WasmMessage {
type:
| "Request"
| "InitialResponse"
| "FrontendUpdate"
| "BackendUpdate"
| "Stop";
connectionId: string;
diff?: Diff;
schema?: CompactShapeType<any>["schema"];
initialData?: LdoCompactBase;
}
function canonicalScope(scope: Scope | undefined): string {
if (scope == null) return "";
@ -35,88 +46,174 @@ export function deepPatchesToDiff(patches: DeepPatch[]): Diff {
}) as Diff;
}
const recurseArrayToSet = (obj: any): any => {
if (Array.isArray(obj)) {
return new Set(obj.map(recurseArrayToSet));
} else if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
obj[key] = recurseArrayToSet(obj[key]);
}
return obj;
} else {
return obj;
}
};
const setUpConnection = (entry: PoolEntry<any>, wasmMessage: WasmMessage) => {
const { connectionId, initialData } = wasmMessage;
const { signalObject } = entry;
// Assign initial data to empty signal object without triggering watcher at first.
entry.suspendDeepWatcher = true;
batch(() => {
// Convert arrays to sets and apply to signalObject (we only have sets but can only transport arrays).
Object.assign(signalObject, recurseArrayToSet(initialData)!);
});
// Add listener to deep signal object to report changes back to wasm land.
const watcher = watch(signalObject, ({ patches }) => {
if (entry.suspendDeepWatcher || !patches.length) return;
const diff = deepPatchesToDiff(patches);
// Send FrontendUpdate message to wasm land.
const msg: WasmMessage = {
type: "FrontendUpdate",
connectionId,
diff: JSON.parse(JSON.stringify(diff)),
};
communicationChannel.postMessage(msg);
});
queueMicrotask(() => {
entry.suspendDeepWatcher = false;
// Resolve readiness after initial data is committed and watcher armed.
entry.resolveReady?.();
});
// Schedule cleanup of the connection when the signal object is GC'd.
cleanupSignalRegistry?.register(
entry.signalObject,
entry.connectionId,
entry.signalObject,
);
entry.ready = true;
};
// Handler for messages from wasm land.
const onWasmMessage = (event: MessageEvent<WasmMessage>) => {
console.debug("[JsLand] onWasmMessage", event);
const { diff, connectionId, type } = event.data;
// Only process messages for objects we track.
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
// And only process messages that are addressed to js-land.
if (type === "FrontendUpdate") return;
if (type === "Request") return;
if (type === "Stop") return;
if (type === "InitialResponse") {
setUpConnection(entry, event.data);
} else if (type === "BackendUpdate" && diff) {
applyDiff(entry.signalObject, diff);
} else {
console.warn("[JsLand] Unknown message type", event);
}
};
const keyToEntry = new Map<string, PoolEntry<any>>();
const connectionIdToEntry = new Map<string, PoolEntry<any>>();
const communicationChannel = new BroadcastChannel("shape-manager");
communicationChannel.addEventListener("message", onWasmMessage);
// FinalizationRegistry to clean up connections when signal objects are GC'd.
const cleanupSignalRegistry =
typeof FinalizationRegistry === "function"
? new FinalizationRegistry<string>((connectionId) => {
// Best-effort fallback; look up by id and clean
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
entry.release();
})
: null;
export function createSignalObjectForShape<T extends LdoCompactBase>(
shape: CompactShapeType<T>,
shapeType: CompactShapeType<T>,
scope?: Scope,
poolSignal = true
) {
const scopeKey = canonicalScope(scope);
const key = `${shape}::${scopeKey}`;
if (poolSignal) {
const existing = pool.get(key);
if (existing) {
existing.refCount++;
return buildReturn(existing);
}
// Unique identifier for a given shape type and scope.
const key = `${shapeType.shape}::${scopeKey}`;
// If we already have an object for this shape+scope, return it
// and just increase the reference count.
const existing = keyToEntry.get(key);
if (existing) {
existing.refCount++;
return buildReturn(existing);
}
// Otherwise, create a new signal object and an entry for it.
const signalObject = deepSignal<T | {}>({});
let resolveReady!: (id: string) => void;
const ready = new Promise<string>((res) => (resolveReady = res));
const entry: PoolEntry<T> = {
key,
shape,
// The id for future communication between wasm and js land.
connectionId: `${key}_${new Date().toISOString()}`,
shapeType,
scopeKey,
signalObject,
refCount: 1,
stopListening: null,
registerCleanup: undefined,
connectionId: undefined,
ready,
resolveReady,
suspendDeepWatcher: false,
};
if (poolSignal) pool.set(key, entry);
const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => {
console.debug("[shape][diff] applying", connectionId, diff);
entry.suspendDeepWatcher = true;
batch(() => applyDiff(signalObject, diff));
queueMicrotask(() => {
entry.suspendDeepWatcher = false;
});
ready: false,
// readyPromise will be set just below
readyPromise: Promise.resolve(),
resolveReady: () => {},
// Function to manually release the connection.
// Only releases if no more references exist.
release: () => {
if (entry.refCount > 0) entry.refCount--;
if (entry.refCount === 0) {
communicationChannel.postMessage({
type: "Stop",
connectionId: entry.connectionId,
} as WasmMessage);
keyToEntry.delete(entry.key);
connectionIdToEntry.delete(entry.connectionId);
// In your manual release
cleanupSignalRegistry?.unregister(entry.signalObject);
}
},
};
requestShape(shape, scope, onUpdateFromDb).then(
({ connectionId, shapeObject }) => {
entry.connectionId = connectionId;
entry.suspendDeepWatcher = true;
batch(() => {
for (const k of Object.keys(shapeObject)) {
(signalObject as any)[k] = (shapeObject as any)[k];
}
});
const watcher = watch(signalObject, ({ patches }) => {
if (entry.suspendDeepWatcher || !patches.length) return;
const diff = deepPatchesToDiff(patches);
updateShape(connectionId as any, diff as any);
});
entry.stopListening = watcher.stopListening;
entry.registerCleanup = watcher.registerCleanup;
queueMicrotask(() => {
entry.suspendDeepWatcher = false;
});
entry.resolveReady(connectionId);
}
);
// Initialize per-entry readiness promise that resolves in setUpConnection
entry.readyPromise = new Promise<void>((resolve) => {
entry.resolveReady = resolve;
});
keyToEntry.set(key, entry);
connectionIdToEntry.set(entry.connectionId, entry);
communicationChannel.postMessage({
type: "Request",
connectionId: entry.connectionId,
schema: shapeType.schema,
} as WasmMessage);
function buildReturn(entry: PoolEntry<T>) {
const release = () => {
if (entry.refCount > 0) entry.refCount--;
if (entry.refCount === 0) {
entry.stopListening?.();
if (poolSignal) pool.delete(entry.key);
}
};
return {
signalObject: entry.signalObject,
stop: release,
ready: entry.ready, // Promise<string>
get connectionId() {
return entry.connectionId;
},
registerCleanup: entry.registerCleanup,
stop: entry.release,
connectionId: entry.connectionId,
readyPromise: entry.readyPromise,
};
}

@ -7,7 +7,7 @@ import type { Scope, Shape } from "src/ng-mock/js-land/types";
const useShape = <T extends LdoCompactBase>(
shape: CompactShapeType<T>,
scope: Scope = ""
scope: Scope = "",
) => {
const shapeSignalRef = useRef<
ReturnType<typeof createSignalObjectForShape<T>>
@ -15,15 +15,19 @@ const useShape = <T extends LdoCompactBase>(
const [, setTick] = useState(0);
useEffect(() => {
const deepSignalObj = shapeSignalRef.current.signalObject;
const { stopListening } = watch(deepSignalObj, () => {
const handle = shapeSignalRef.current;
const deepSignalObj = handle.signalObject;
const stopListening = watch(deepSignalObj, () => {
// trigger a React re-render when the deep signal updates
setTick((t) => t + 1);
});
// Ensure first render after initial data is applied
handle.readyPromise?.then(() => setTick((t) => t + 1));
return () => {
shapeSignalRef.current.stop();
stopListening();
handle.stop();
};
}, []);

@ -1,99 +0,0 @@
import * as shapeManager from "./shapeManager";
import type { WasmConnection, Diff, Scope } from "./types";
import type { CompactShapeType } from "@ldo/ldo";
import type { LdoCompactBase } from "@ldo/ldo";
import type { Person } from "src/shapes/ldo/personShape.typings";
import type { Cat } from "src/shapes/ldo/catShape.typings";
import type { TestObject } from "src/shapes/ldo/testShape.typings";
export const mockTestObject = {
id: "ex:mock-id-1",
type: "TestObject",
stringValue: "string",
numValue: 42,
boolValue: true,
arrayValue: new Set([1, 2, 3]),
objectValue: {
nestedString: "nested",
nestedNum: 7,
nestedArray: new Set([10, 12]),
},
anotherObject: {
"id:1": {
id: "id:1",
prop1: "prop1 value",
prop2: 100,
},
"id:2": {
id: "id:1",
prop1: "prop2 value",
prop2: 200,
},
},
} satisfies TestObject;
const mockShapeObject1 = {
id: "ex:person-1",
type: "Person",
name: "Bob",
address: {
street: "First street",
houseNumber: "15",
},
hasChildren: true,
numberOfHouses: 0,
} satisfies Person;
const mockShapeObject2 = {
id: "ex:cat-1",
type: "Cat",
name: "Niko's cat",
age: 12,
numberOfHomes: 3,
address: {
street: "Niko's street",
houseNumber: "15",
floor: 0,
},
} satisfies Cat;
let connectionIdCounter = 1;
export default async function requestShape<T extends LdoCompactBase>(
shape: CompactShapeType<T>,
scope: Scope | undefined,
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void
): Promise<{
connectionId: string;
shapeObject: T;
}> {
const connectionId = `connection-${connectionIdCounter++}-${
shape.schema.shapes?.[0].id
}`;
let shapeObject: T;
if (shape.schema.shapes?.[0].id.includes("TestObject")) {
shapeObject = mockTestObject as T;
} else if (shape.schema.shapes?.[0].id.includes("Person")) {
shapeObject = mockShapeObject1 as T;
} else if (shape.schema.shapes?.[0].id.includes("Cat")) {
shapeObject = mockShapeObject2 as T;
} else {
console.warn(
"BACKEND: requestShape for unknown shape, returning empty object.",
shape.schema.shapes?.[0].id
);
shapeObject = {};
}
shapeManager.connections.set(connectionId, {
id: connectionId,
shape,
state: shapeObject,
callback,
});
return {
connectionId,
shapeObject,
};
}

@ -0,0 +1,255 @@
import * as shapeManager from "./shapeManager";
import type { WasmConnection, Diff, Scope } from "./types";
import type { CompactShapeType, LdoCompactBase } from "@ldo/ldo";
import type { Person } from "src/shapes/ldo/personShape.typings";
import type { Cat } from "src/shapes/ldo/catShape.typings";
import type { TestObject } from "src/shapes/ldo/testShape.typings";
import updateShape from "./updateShape";
// Messages exchanged over the BroadcastChannel("shape-manager")
interface WasmMessage {
type:
| "Request"
| "InitialResponse"
| "FrontendUpdate"
| "BackendUpdate"
| "Stop";
connectionId: string;
diff?: Diff;
schema?: CompactShapeType<any>["schema"];
initialData?: LdoCompactBase;
}
export const mockTestObject = {
id: "ex:mock-id-1",
type: "TestObject",
stringValue: "string",
numValue: 42,
boolValue: true,
arrayValue: [1, 2, 3],
objectValue: {
id: "urn:obj-1",
nestedString: "nested",
nestedNum: 7,
nestedArray: [10, 12],
},
anotherObject: {
"id:1": {
id: "id:1",
prop1: "prop1 value",
prop2: 100,
},
"id:2": {
id: "id:1",
prop1: "prop2 value",
prop2: 200,
},
},
} satisfies TestObject;
const mockShapeObject1 = {
id: "ex:person-1",
type: "Person",
name: "Bob",
address: {
id: "urn:person-home-1",
street: "First street",
houseNumber: "15",
},
hasChildren: true,
numberOfHouses: 0,
} satisfies Person;
const mockShapeObject2 = {
id: "ex:cat-1",
type: "Cat",
name: "Niko's cat",
age: 12,
numberOfHomes: 3,
address: {
id: "Nikos-cat-home",
street: "Niko's street",
houseNumber: "15",
floor: 0,
},
} satisfies Cat;
// Single BroadcastChannel for wasm-land side
const communicationChannel = new BroadcastChannel("shape-manager");
function getInitialObjectByShapeId<T extends LdoCompactBase>(
shapeId?: string,
): T {
if (shapeId?.includes("TestObject")) return mockTestObject as unknown as T;
if (shapeId?.includes("Person")) return mockShapeObject1 as unknown as T;
if (shapeId?.includes("Cat")) return mockShapeObject2 as unknown as T;
console.warn(
"BACKEND: requestShape for unknown shape, returning empty object.",
shapeId,
);
return {} as T;
}
// Register handler for messages coming from js-land
communicationChannel.addEventListener(
"message",
(event: MessageEvent<WasmMessage>) => {
console.log("BACKEND: Received message", event.data);
const { type, connectionId, schema } = event.data;
if (type === "Request") {
const shapeId = schema?.shapes?.[0]?.id;
const initialData = getInitialObjectByShapeId(shapeId);
// Store connection. We store the shapeId string to allow equality across connections.
shapeManager.connections.set(connectionId, {
id: connectionId,
// Cast to any to satisfy WasmConnection type, comparison in updateShape uses ==
shape: (shapeId ?? "__unknown__") as any,
state: initialData,
callback: (diff: Diff, conId: WasmConnection["id"]) => {
// Notify js-land about backend updates
const msg: WasmMessage = {
type: "BackendUpdate",
connectionId: conId,
diff,
};
communicationChannel.postMessage(msg);
},
});
const msg: WasmMessage = {
type: "InitialResponse",
connectionId,
initialData,
};
communicationChannel.postMessage(msg);
return;
}
if (type === "Stop") {
shapeManager.connections.delete(connectionId);
return;
}
if (type === "FrontendUpdate" && event.data.diff) {
updateShape(connectionId, event.data.diff);
return;
}
console.warn("BACKEND: Unknown message type or missing diff", event.data);
},
);
// Keep the original function for compatibility with any direct callers.
let connectionIdCounter = 1;
export default async function requestShape<T extends LdoCompactBase>(
shape: CompactShapeType<T>,
_scope: Scope | undefined,
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void,
): Promise<{ connectionId: string; shapeObject: T }> {
const connectionId = `connection-${connectionIdCounter++}-${shape.schema.shapes?.[0]?.id}`;
const shapeId = shape.schema.shapes?.[0]?.id;
const shapeObject = getInitialObjectByShapeId<T>(shapeId);
shapeManager.connections.set(connectionId, {
id: connectionId,
shape: (shapeId ?? "__unknown__") as any,
state: shapeObject,
callback,
});
return { connectionId, shapeObject };
}
const getObjectsForShapeType = <T extends LdoCompactBase>(
shape: CompactShapeType<T>,
scope: string = "",
): T[] => {
// Procedure
// - Get all triples for the scope
// - Parse the schema (all shapes and anonymous shapes required for the shape type).
// - Group triples by subject
// - For the shapeType in the schema, match all required predicates
// - For predicates pointing to nested objects
// - recurse
// Repeat procedure for all matched subjects with optional predicates
const quads: [
string,
string,
number | string | boolean,
string | undefined,
][] = [];
// The URI of the shape to find matches for.
const schemaId = shape.shape;
// ShexJ shape object
const rootShapeDecl = shape.schema.shapes?.find(
(shape) => shape.id === schemaId,
);
if (!rootShapeDecl)
throw new Error(`Could not find shape id ${schemaId} in shape schema`);
if (rootShapeDecl.shapeExpr.type !== "Shape")
throw new Error("Expected shapeExpr.type to be Shape");
const shapeExpression = rootShapeDecl.shapeExpr.expression;
// If shape is a reference...
if (typeof shapeExpression === "string") {
// TODO: Recurse
return [];
}
const requiredPredicates = [];
const optionalPredicates = [];
if (shapeExpression?.type === "EachOf") {
const predicates = shapeExpression.expressions.map((constraint) => {
if (typeof constraint === "string") {
// Cannot parse constraint refs
return;
} else if (constraint.type === "TripleConstraint") {
requiredPredicates.push({
predicate: constraint.predicate,
});
} else {
// EachOf or OneOf possible?
}
});
} else if (shapeExpression?.type === "OneOf") {
// Does not occur AFAIK.
} else if (shapeExpression?.type === "TripleConstraint") {
// Does not occur AFAIK.
}
return [];
};
interface ShapeConstraintTracked {
subject: string;
childOf?: ShapeConstraintTracked;
predicates: [
{
displayName: string;
uri: string;
type: "number" | "string" | "boolean" | "nested" | "literal";
literalValue?: number | string | boolean | number[] | string[];
nested?: ShapeConstraintTracked;
min: number;
max: number;
currentCount: number;
},
];
}
// Group by subject, check predicates of root level
// For all subjects of root level,
// - recurse
// Construct matching subjects
// for each optional and non-optional predicate
// - fill objects and record
// - build tracked object (keeping reference counts to check if the object is still valid)

@ -0,0 +1,14 @@
# SPARQL builders
Utilities to build SPARQL SELECT and CONSTRUCT queries from a ShapeConstraint structure.
Exports:
- buildSelectQuery(shape, options)
- buildConstructQuery(shape, options)
Options:
- prefixes: Record<prefix, IRI>
- graph: named graph IRI or CURIE
- includeOptionalForMinZero: wrap min=0 predicates in OPTIONAL (default true)

@ -0,0 +1,149 @@
import type {
BuildContext,
PredicateConstraint,
ShapeConstraint,
SparqlBuildOptions,
} from "./common";
import {
predicateToSparql,
prefixesToText,
toIriOrCurie,
uniqueVar,
valuesBlock,
varToken,
} from "./common";
/**
* Build a SPARQL CONSTRUCT query from a ShapeConstraint definition.
* The WHERE mirrors the graph template. Optional predicates (min=0) are wrapped in OPTIONAL in WHERE
* but still appear in the CONSTRUCT template so that matched triples are constructed.
*/
export function buildConstructQuery(
shape: ShapeConstraint,
options?: SparqlBuildOptions,
): string {
const ctx: BuildContext = { usedVars: new Set<string>() };
const prefixes = prefixesToText(options?.prefixes);
const subject = toIriOrCurie(shape.subject);
const templateLines: string[] = [];
const whereLines: string[] = [];
const postFilters: string[] = [];
const valuesBlocks: string[] = [];
const rootVar =
subject.startsWith("?") || subject.startsWith("$")
? subject
: uniqueVar(ctx, "s");
if (!subject.startsWith("?") && !subject.startsWith("$")) {
valuesBlocks.push(valuesBlock(rootVar, [subject] as any));
}
const predicates = Array.isArray(shape.predicates)
? shape.predicates
: [...shape.predicates];
for (const pred of predicates) {
addConstructPattern(
ctx,
pred,
rootVar,
templateLines,
whereLines,
postFilters,
valuesBlocks,
options,
);
}
const graphWrap = (body: string) =>
options?.graph
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}`
: body;
const where = [
...valuesBlocks,
graphWrap(whereLines.join("\n")),
...postFilters,
]
.filter(Boolean)
.join("\n");
const template = templateLines.join("\n");
return [prefixes, `CONSTRUCT {`, template, `} WHERE {`, where, `}`].join(
"\n",
);
}
function addConstructPattern(
ctx: BuildContext,
pred: PredicateConstraint,
subjectVar: string,
template: string[],
where: string[],
postFilters: string[],
valuesBlocks: string[],
options?: SparqlBuildOptions,
) {
const p = predicateToSparql(pred.uri);
const objVar = uniqueVar(ctx, pred.displayName || "o");
const objTerm =
pred.type === "nested" &&
pred.nested?.subject &&
!pred.nested.subject.match(/^\?|^\$/)
? toIriOrCurie(pred.nested.subject)
: objVar;
const triple = `${subjectVar} ${p} ${objTerm} .`;
const isOptional =
(pred.min ?? 0) === 0 && (options?.includeOptionalForMinZero ?? true);
if (pred.type === "nested" && pred.nested) {
template.push(triple);
const nestedBody: string[] = [triple];
const nestedPreds = Array.isArray(pred.nested.predicates)
? pred.nested.predicates
: [...pred.nested.predicates];
for (const n of nestedPreds) {
addConstructPattern(
ctx,
n,
objTerm,
template,
nestedBody,
postFilters,
valuesBlocks,
options,
);
}
const block = nestedBody.join("\n");
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block);
return;
}
// Non-nested
template.push(triple);
const blockLines: string[] = [triple];
if (pred.type === "literal" && pred.literalValue !== undefined) {
if (Array.isArray(pred.literalValue)) {
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[]));
} else {
const lit =
typeof pred.literalValue === "string" ||
typeof pred.literalValue === "number" ||
typeof pred.literalValue === "boolean"
? pred.literalValue
: String(pred.literalValue);
postFilters.push(
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})`,
);
}
}
const block = blockLines.join("\n");
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block);
}
export default buildConstructQuery;

@ -0,0 +1,152 @@
import type {
BuildContext,
PredicateConstraint,
ShapeConstraint,
SparqlBuildOptions,
} from "./common";
import {
predicateToSparql,
prefixesToText,
toIriOrCurie,
uniqueVar,
valuesBlock,
varToken,
} from "./common";
/**
* Build a SPARQL SELECT query from a ShapeConstraint definition.
* The query matches the shape subject and constraints; optional predicates (min=0) are wrapped in OPTIONAL.
*/
export function buildSelectQuery(
shape: ShapeConstraint,
options?: SparqlBuildOptions,
): string {
const ctx: BuildContext = { usedVars: new Set<string>() };
const prefixes = prefixesToText(options?.prefixes);
const subject = toIriOrCurie(shape.subject);
const selectVars: string[] = [];
const whereLines: string[] = [];
const postFilters: string[] = [];
const valuesBlocks: string[] = [];
// ensure a consistent root variable when subject is a variable
const rootVar =
subject.startsWith("?") || subject.startsWith("$")
? subject
: uniqueVar(ctx, "s");
if (!subject.startsWith("?") && !subject.startsWith("$")) {
// bind fixed subject via VALUES for portability
valuesBlocks.push(valuesBlock(rootVar, [subject] as any));
}
const predicates = Array.isArray(shape.predicates)
? shape.predicates
: [...shape.predicates];
for (const pred of predicates) {
addPredicatePattern(
ctx,
pred,
rootVar,
whereLines,
selectVars,
postFilters,
valuesBlocks,
options,
);
}
const graphWrap = (body: string) =>
options?.graph
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}`
: body;
const where = [
...valuesBlocks,
graphWrap(whereLines.join("\n")),
...postFilters,
]
.filter(Boolean)
.join("\n");
const select = selectVars.length ? selectVars.join(" ") : "*";
return [prefixes, `SELECT ${select} WHERE {`, where, `}`].join("\n");
}
function addPredicatePattern(
ctx: BuildContext,
pred: PredicateConstraint,
subjectVar: string,
where: string[],
selectVars: string[],
postFilters: string[],
valuesBlocks: string[],
options?: SparqlBuildOptions,
) {
const p = predicateToSparql(pred.uri);
const objVar = uniqueVar(ctx, pred.displayName || "o");
const objTerm =
pred.type === "nested" &&
pred.nested?.subject &&
!pred.nested.subject.match(/^\?|^\$/)
? toIriOrCurie(pred.nested.subject)
: objVar;
const triple = `${subjectVar} ${p} ${objTerm} .`;
const isOptional =
(pred.min ?? 0) === 0 && (options?.includeOptionalForMinZero ?? true);
if (pred.type === "nested" && pred.nested) {
// For nested, we select the nested object var and then recurse
if (objTerm === objVar) selectVars.push(objVar);
const nestedBody: string[] = [triple];
const nestedPreds = Array.isArray(pred.nested.predicates)
? pred.nested.predicates
: [...pred.nested.predicates];
for (const n of nestedPreds) {
addPredicatePattern(
ctx,
n,
objTerm,
nestedBody,
selectVars,
postFilters,
valuesBlocks,
options,
);
}
const block = nestedBody.join("\n");
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block);
return;
}
// Non-nested: literals or IRIs
selectVars.push(objVar);
const blockLines: string[] = [triple];
if (pred.type === "literal" && pred.literalValue !== undefined) {
if (Array.isArray(pred.literalValue)) {
// VALUES block for IN-like matching
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[]));
} else {
// simple equality filter
const lit =
typeof pred.literalValue === "string" ||
typeof pred.literalValue === "number" ||
typeof pred.literalValue === "boolean"
? pred.literalValue
: String(pred.literalValue);
postFilters.push(
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})`,
);
}
}
const block = blockLines.join("\n");
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block);
}
export default buildSelectQuery;

@ -0,0 +1,125 @@
/**
* Shared helpers and types to build SPARQL queries from ShapeConstraint
*/
export type LiteralKind =
| "number"
| "string"
| "boolean"
| "nested"
| "literal";
export interface PredicateConstraint {
displayName: string;
uri: string;
type: LiteralKind;
literalValue?: number | string | boolean | number[] | string[];
nested?: ShapeConstraint;
min: number;
max: number;
currentCount: number;
}
export interface ShapeConstraint {
subject: string;
// In upstream code this is typed as a 1-length tuple; we normalize to an array here
predicates: PredicateConstraint[] | [PredicateConstraint];
}
export interface SparqlBuildOptions {
prefixes?: Record<string, string>;
graph?: string; // IRI of the named graph to query, if any
includeOptionalForMinZero?: boolean; // default true
}
export const defaultPrefixes: Record<string, string> = {
xsd: "http://www.w3.org/2001/XMLSchema#",
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
};
export function prefixesToText(prefixes?: Record<string, string>): string {
const all = { ...defaultPrefixes, ...(prefixes ?? {}) };
return Object.entries(all)
.map(([p, iri]) => `PREFIX ${p}: <${iri}>`)
.join("\n");
}
export function toIriOrCurie(term: string): string {
// variable
if (term.startsWith("?") || term.startsWith("$")) return term;
// blank node
if (term.startsWith("_:")) return term;
// full IRI
if (term.includes("://")) return `<${term}>`;
// fallback: assume CURIE or already-angled
if (term.startsWith("<") && term.endsWith(">")) return term;
return term; // CURIE, caller must ensure prefix provided
}
export function predicateToSparql(uri: string): string {
// Allow CURIEs or IRIs
return toIriOrCurie(uri);
}
export function safeVarName(name: string): string {
const base = name
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/^([0-9])/, "_$1")
.slice(0, 60);
return base || "v";
}
export function varToken(name: string): string {
const n = name.startsWith("?") || name.startsWith("$") ? name.slice(1) : name;
return `?${safeVarName(n)}`;
}
export function formatLiteral(value: string | number | boolean): string {
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return value ? "true" : "false";
// default string literal
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
export function formatTermForValues(value: string | number | boolean): string {
if (typeof value === "number" || typeof value === "boolean")
return formatLiteral(value);
// strings: detect IRI or CURIE and keep raw; otherwise quote
const v = value.trim();
const looksLikeIri = v.startsWith("<") && v.endsWith(">");
const looksLikeHttp = v.includes("://");
const looksLikeCurie =
/^[A-Za-z_][A-Za-z0-9_-]*:.+$/u.test(v) && !looksLikeHttp;
if (looksLikeIri || looksLikeHttp || looksLikeCurie) {
return looksLikeHttp ? `<${v}>` : v;
}
return formatLiteral(v);
}
export function valuesBlock(
varName: string,
values: Array<string | number | boolean>,
): string {
const rendered = values.map(formatTermForValues).join(" ");
return `VALUES ${varName} { ${rendered} }`;
}
export interface BuildContext {
// Tracks used variable names to avoid collisions
usedVars: Set<string>;
}
export function uniqueVar(ctx: BuildContext, base: string): string {
let candidate = varToken(base);
if (!ctx.usedVars.has(candidate)) {
ctx.usedVars.add(candidate);
return candidate;
}
let i = 2;
while (ctx.usedVars.has(`${candidate}_${i}`)) i++;
const unique = `${candidate}_${i}`;
ctx.usedVars.add(unique);
return unique;
}

@ -3,7 +3,7 @@ import type { WasmConnection, Diff } from "./types";
export default async function updateShape(
connectionId: WasmConnection["id"],
diff: Diff
diff: Diff,
) {
const connection = shapeManager.connections.get(connectionId);
if (!connection) throw new Error("No Connection found.");
@ -14,9 +14,9 @@ export default async function updateShape(
connection.state = newState;
shapeManager.connections.forEach((con) => {
if (con.shape == connection.shape) {
con.state = newState;
con.callback(diff, con.id);
}
// if (con.shape == connection.shape) {
// con.state = newState;
// con.callback(diff, con.id);
// }
});
}

@ -0,0 +1,77 @@
import type { CompactSchema } from "@ldo/ldo";
/**
* =============================================================================
* catShapeSchema: Compact Schema for catShape
* =============================================================================
*/
export const catShapeSchema: CompactSchema = {
"http://example.org/Cat": {
schemaUri: "http://example.org/Cat",
predicates: [
{
type: "literal",
literalValue: ["Cat"],
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
},
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/name",
readablePredicate: "name",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/age",
readablePredicate: "age",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/numberOfHomes",
readablePredicate: "numberOfHomes",
},
{
type: "nested",
nestedSchema: "http://example.org/Cat::http://example.org/address",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/address",
readablePredicate: "address",
},
],
},
"http://example.org/Cat::http://example.org/address": {
schemaUri: "http://example.org/Cat::http://example.org/address",
predicates: [
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/street",
readablePredicate: "street",
},
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/houseNumber",
readablePredicate: "houseNumber",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/floor",
readablePredicate: "floor",
},
],
},
};

@ -1,104 +0,0 @@
import type { Schema } from "shexj";
/**
* =============================================================================
* catShapeSchema: ShexJ Schema for catShape
* =============================================================================
*/
export const catShapeSchema: Schema = {
type: "Schema",
shapes: [
{
id: "http://example.org/Cat",
type: "ShapeDecl",
shapeExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
valueExpr: {
type: "NodeConstraint",
values: [
{
value: "Cat",
},
],
},
readablePredicate: "type",
},
{
type: "TripleConstraint",
predicate: "http://example.org/name",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "name",
},
{
type: "TripleConstraint",
predicate: "http://example.org/age",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "age",
},
{
type: "TripleConstraint",
predicate: "http://example.org/numberOfHomes",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "numberOfHomes",
},
{
type: "TripleConstraint",
predicate: "http://example.org/address",
valueExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://example.org/street",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "street",
},
{
type: "TripleConstraint",
predicate: "http://example.org/houseNumber",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "houseNumber",
},
{
type: "TripleConstraint",
predicate: "http://example.org/floor",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "floor",
},
],
},
},
readablePredicate: "address",
},
],
},
},
},
],
};

@ -0,0 +1,70 @@
import type { CompactSchema } from "@ldo/ldo";
/**
* =============================================================================
* personShapeSchema: Compact Schema for personShape
* =============================================================================
*/
export const personShapeSchema: CompactSchema = {
"http://example.org/Person": {
schemaUri: "http://example.org/Person",
predicates: [
{
type: "literal",
literalValue: ["Person"],
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
},
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/name",
readablePredicate: "name",
},
{
type: "nested",
nestedSchema: "http://example.org/Person::http://example.org/address",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/address",
readablePredicate: "address",
},
{
type: "boolean",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/hasChildren",
readablePredicate: "hasChildren",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/numberOfHouses",
readablePredicate: "numberOfHouses",
},
],
},
"http://example.org/Person::http://example.org/address": {
schemaUri: "http://example.org/Person::http://example.org/address",
predicates: [
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/street",
readablePredicate: "street",
},
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/houseNumber",
readablePredicate: "houseNumber",
},
],
},
};

@ -1,95 +0,0 @@
import type { Schema } from "shexj";
/**
* =============================================================================
* personShapeSchema: ShexJ Schema for personShape
* =============================================================================
*/
export const personShapeSchema: Schema = {
type: "Schema",
shapes: [
{
id: "http://example.org/Person",
type: "ShapeDecl",
shapeExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
valueExpr: {
type: "NodeConstraint",
values: [
{
value: "Person",
},
],
},
readablePredicate: "type",
},
{
type: "TripleConstraint",
predicate: "http://example.org/name",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "name",
},
{
type: "TripleConstraint",
predicate: "http://example.org/address",
valueExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://example.org/street",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "street",
},
{
type: "TripleConstraint",
predicate: "http://example.org/houseNumber",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "houseNumber",
},
],
},
},
readablePredicate: "address",
},
{
type: "TripleConstraint",
predicate: "http://example.org/hasChildren",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#boolean",
},
readablePredicate: "hasChildren",
},
{
type: "TripleConstraint",
predicate: "http://example.org/numberOfHouses",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "numberOfHouses",
},
],
},
},
},
],
};

@ -0,0 +1,114 @@
import type { CompactSchema } from "@ldo/ldo";
/**
* =============================================================================
* testShapeSchema: Compact Schema for testShape
* =============================================================================
*/
export const testShapeSchema: CompactSchema = {
"http://example.org/TestObject": {
schemaUri: "http://example.org/TestObject",
predicates: [
{
type: "literal",
literalValue: ["TestObject"],
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
},
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/stringValue",
readablePredicate: "stringValue",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/numValue",
readablePredicate: "numValue",
},
{
type: "boolean",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/boolValue",
readablePredicate: "boolValue",
},
{
type: "number",
maxCardinality: -1,
minCardinality: 0,
predicateUri: "http://example.org/arrayValue",
readablePredicate: "arrayValue",
},
{
type: "nested",
nestedSchema:
"http://example.org/TestObject::http://example.org/objectValue",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/objectValue",
readablePredicate: "objectValue",
},
{
type: "nested",
nestedSchema:
"http://example.org/TestObject::http://example.org/anotherObject",
maxCardinality: -1,
minCardinality: 0,
predicateUri: "http://example.org/anotherObject",
readablePredicate: "anotherObject",
},
],
},
"http://example.org/TestObject::http://example.org/objectValue": {
schemaUri: "http://example.org/TestObject::http://example.org/objectValue",
predicates: [
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/nestedString",
readablePredicate: "nestedString",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/nestedNum",
readablePredicate: "nestedNum",
},
{
type: "number",
maxCardinality: -1,
minCardinality: 0,
predicateUri: "http://example.org/nestedArray",
readablePredicate: "nestedArray",
},
],
},
"http://example.org/TestObject::http://example.org/anotherObject": {
schemaUri:
"http://example.org/TestObject::http://example.org/anotherObject",
predicates: [
{
type: "string",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/prop1",
readablePredicate: "prop1",
},
{
type: "number",
maxCardinality: 1,
minCardinality: 1,
predicateUri: "http://example.org/prop2",
readablePredicate: "prop2",
},
],
},
};

@ -1,150 +0,0 @@
import type { Schema } from "shexj";
/**
* =============================================================================
* testShapeSchema: ShexJ Schema for testShape
* =============================================================================
*/
export const testShapeSchema: Schema = {
type: "Schema",
shapes: [
{
id: "http://example.org/TestObject",
type: "ShapeDecl",
shapeExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
valueExpr: {
type: "NodeConstraint",
values: [
{
value: "TestObject",
},
],
},
readablePredicate: "type",
},
{
type: "TripleConstraint",
predicate: "http://example.org/stringValue",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "stringValue",
},
{
type: "TripleConstraint",
predicate: "http://example.org/numValue",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "numValue",
},
{
type: "TripleConstraint",
predicate: "http://example.org/boolValue",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#boolean",
},
readablePredicate: "boolValue",
},
{
type: "TripleConstraint",
predicate: "http://example.org/arrayValue",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
min: 0,
max: -1,
readablePredicate: "arrayValue",
},
{
type: "TripleConstraint",
predicate: "http://example.org/objectValue",
valueExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://example.org/nestedString",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "nestedString",
},
{
type: "TripleConstraint",
predicate: "http://example.org/nestedNum",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "nestedNum",
},
{
type: "TripleConstraint",
predicate: "http://example.org/nestedArray",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
min: 0,
max: -1,
readablePredicate: "nestedArray",
},
],
},
},
readablePredicate: "objectValue",
},
{
type: "TripleConstraint",
predicate: "http://example.org/anotherObject",
valueExpr: {
type: "Shape",
expression: {
type: "EachOf",
expressions: [
{
type: "TripleConstraint",
predicate: "http://example.org/prop1",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#string",
},
readablePredicate: "prop1",
},
{
type: "TripleConstraint",
predicate: "http://example.org/prop2",
valueExpr: {
type: "NodeConstraint",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
},
readablePredicate: "prop2",
},
],
},
},
min: 0,
max: -1,
readablePredicate: "anotherObject",
},
],
},
},
},
],
};
Loading…
Cancel
Save