parent
43c3356942
commit
2dc6136d51
@ -1,217 +0,0 @@ |
|||||||
import { describe, test, expect } from "vitest"; |
|
||||||
import { |
|
||||||
applyDiff, |
|
||||||
} from "@nextgraph-monorepo/ng-signals"; |
|
||||||
import type { Patch } from "@nextgraph-monorepo/ng-signals"; |
|
||||||
|
|
||||||
/** |
|
||||||
* Build a patch path string from segments (auto-prefix /) |
|
||||||
*/ |
|
||||||
function p(...segs: (string | number)[]) { |
|
||||||
return "/" + segs.map(String).join("/"); |
|
||||||
} |
|
||||||
|
|
||||||
describe("applyDiff - set operations (primitives)", () => { |
|
||||||
test("add single primitive into new set", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", type: "set", path: p("tags"), value: "a" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.tags).toBeInstanceOf(Set); |
|
||||||
expect([...state.tags]).toEqual(["a"]); |
|
||||||
}); |
|
||||||
test("add multiple primitives into new set", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", type: "set", path: p("nums"), value: [1, 2, 3] }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect([...state.nums]).toEqual([1, 2, 3]); |
|
||||||
}); |
|
||||||
test("add primitives merging into existing set", () => { |
|
||||||
const state: any = { nums: new Set([1]) }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", type: "set", path: p("nums"), value: [2, 3] }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect([...state.nums].sort()).toEqual([1, 2, 3]); |
|
||||||
}); |
|
||||||
test("remove single primitive from set", () => { |
|
||||||
const state: any = { tags: new Set(["a", "b"]) }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "remove", type: "set", path: p("tags"), value: "a" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect([...state.tags]).toEqual(["b"]); |
|
||||||
}); |
|
||||||
test("remove multiple primitives from set", () => { |
|
||||||
const state: any = { nums: new Set([1, 2, 3, 4]) }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "remove", type: "set", path: p("nums"), value: [2, 4] }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect([...state.nums].sort()).toEqual([1, 3]); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("applyDiff - set operations (object sets)", () => { |
|
||||||
test("add object entries to new object-set", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ |
|
||||||
op: "add", |
|
||||||
type: "set", |
|
||||||
path: p("users"), |
|
||||||
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, |
|
||||||
}, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.users.u1).toEqual({ id: "u1", n: 1 }); |
|
||||||
expect(state.users.u2).toEqual({ id: "u2", n: 2 }); |
|
||||||
}); |
|
||||||
test("merge object entries into existing object-set", () => { |
|
||||||
const state: any = { users: { u1: { id: "u1", n: 1 } } }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ |
|
||||||
op: "add", |
|
||||||
type: "set", |
|
||||||
path: p("users"), |
|
||||||
value: { u2: { id: "u2", n: 2 } }, |
|
||||||
}, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
|
||||||
}); |
|
||||||
test("remove object entries from object-set", () => { |
|
||||||
const state: any = { users: { u1: {}, u2: {}, u3: {} } }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "remove", type: "set", path: p("users"), value: ["u1", "u3"] }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(Object.keys(state.users)).toEqual(["u2"]); |
|
||||||
}); |
|
||||||
test("adding primitives to existing object-set replaces with Set", () => { |
|
||||||
const state: any = { mixed: { a: {}, b: {} } }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", type: "set", path: p("mixed"), value: [1, 2] }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.mixed).toBeInstanceOf(Set); |
|
||||||
expect([...state.mixed]).toEqual([1, 2]); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("applyDiff - object & literal operations", () => { |
|
||||||
test("add object (create empty object)", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [{ op: "add", path: p("address"), type: "object" }]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.address).toEqual({}); |
|
||||||
}); |
|
||||||
test("add nested object path with ensurePathExists", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", path: p("a", "b", "c"), type: "object" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff, true); |
|
||||||
expect(state.a.b.c).toEqual({}); |
|
||||||
}); |
|
||||||
test("add primitive value", () => { |
|
||||||
const state: any = { address: {} }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", path: p("address", "street"), value: "1st" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.address.street).toBe("1st"); |
|
||||||
}); |
|
||||||
test("overwrite primitive value", () => { |
|
||||||
const state: any = { address: { street: "old" } }; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", path: p("address", "street"), value: "new" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.address.street).toBe("new"); |
|
||||||
}); |
|
||||||
test("remove primitive", () => { |
|
||||||
const state: any = { address: { street: "1st", country: "Greece" } }; |
|
||||||
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.address.street).toBeUndefined(); |
|
||||||
expect(state.address.country).toBe("Greece"); |
|
||||||
}); |
|
||||||
test("remove object branch", () => { |
|
||||||
const state: any = { address: { street: "1st" }, other: 1 }; |
|
||||||
const diff: Patch[] = [{ op: "remove", path: p("address") }]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state.address).toBeUndefined(); |
|
||||||
expect(state.other).toBe(1); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("applyDiff - multiple mixed patches in a single diff", () => { |
|
||||||
test("sequence of mixed set/object/literal add & remove", () => { |
|
||||||
const state: any = { |
|
||||||
users: { u1: { id: "u1" } }, |
|
||||||
tags: new Set(["old"]), |
|
||||||
}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", type: "set", path: p("users"), value: { u2: { id: "u2" } } }, |
|
||||||
{ op: "add", path: p("profile"), type: "object" }, |
|
||||||
{ op: "add", path: p("profile", "name"), value: "Alice" }, |
|
||||||
{ op: "add", type: "set", path: p("tags"), value: ["new"] }, |
|
||||||
{ op: "remove", type: "set", path: p("tags"), value: "old" }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
|
||||||
expect(state.profile.name).toBe("Alice"); |
|
||||||
expect([...state.tags]).toEqual(["new"]); |
|
||||||
}); |
|
||||||
|
|
||||||
test("complex nested path creation and mutations with ensurePathExists", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [ |
|
||||||
{ op: "add", path: p("a", "b"), type: "object" }, |
|
||||||
{ op: "add", path: p("a", "b", "c"), value: 1 }, |
|
||||||
{ op: "add", type: "set", path: p("a", "nums"), value: [1, 2, 3] }, |
|
||||||
{ op: "remove", type: "set", path: p("a", "nums"), value: 2 }, |
|
||||||
{ op: "add", path: p("a", "b", "d"), value: 2 }, |
|
||||||
{ op: "remove", path: p("a", "b", "c") }, |
|
||||||
]; |
|
||||||
applyDiff(state, diff, true); |
|
||||||
expect(state.a.b.c).toBeUndefined(); |
|
||||||
expect(state.a.b.d).toBe(2); |
|
||||||
expect(state.a.nums).toBeInstanceOf(Set); |
|
||||||
expect([...state.a.nums].sort()).toEqual([1, 3]); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("applyDiff - ignored / invalid scenarios", () => { |
|
||||||
test("skip patch with non-leading slash path", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state).toEqual({}); |
|
||||||
}); |
|
||||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
|
||||||
applyDiff(state, diff, false); |
|
||||||
expect(state).toEqual({}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe("applyDiff - ignored / invalid scenarios", () => { |
|
||||||
test("skip patch with non-leading slash path", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
|
||||||
applyDiff(state, diff); |
|
||||||
expect(state).toEqual({}); |
|
||||||
}); |
|
||||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
|
||||||
const state: any = {}; |
|
||||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
|
||||||
applyDiff(state, diff, false); |
|
||||||
expect(state).toEqual({}); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,56 +0,0 @@ |
|||||||
import { describe, expect, test } from "vitest"; |
|
||||||
import { createSignalObjectForShape } from "@nextgraph-monorepo/ng-signals"; |
|
||||||
|
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
|
||||||
|
|
||||||
describe("Signal modification and propagation to backend with or without signal pooling", () => { |
|
||||||
for (const withPooling of [true, false]) { |
|
||||||
test(`shape object notification comes back to others ${ |
|
||||||
withPooling ? "with" : "without" |
|
||||||
} signal pooling`, async () => {
|
|
||||||
const object1 = createSignalObjectForShape( |
|
||||||
"TestShape", |
|
||||||
undefined, |
|
||||||
withPooling |
|
||||||
); |
|
||||||
const object2 = createSignalObjectForShape( |
|
||||||
"TestShape", |
|
||||||
undefined, |
|
||||||
withPooling |
|
||||||
); |
|
||||||
|
|
||||||
const object3 = createSignalObjectForShape( |
|
||||||
"Shape2", |
|
||||||
undefined, |
|
||||||
withPooling |
|
||||||
); |
|
||||||
const object4 = createSignalObjectForShape( |
|
||||||
"Shape2", |
|
||||||
undefined, |
|
||||||
withPooling |
|
||||||
); |
|
||||||
|
|
||||||
await wait(10); |
|
||||||
|
|
||||||
// Update object 1 and expect object 2 to update as well.
|
|
||||||
// @ts-expect-error
|
|
||||||
object1.name = "Updated name from object1"; |
|
||||||
|
|
||||||
await wait(10); |
|
||||||
// @ts-expect-error
|
|
||||||
expect(object2.name).toBe("Updated name from object1"); |
|
||||||
|
|
||||||
// Expect object of different shape not to have changed.
|
|
||||||
// @ts-expect-error
|
|
||||||
expect(object3.name).toBe("Niko's cat"); |
|
||||||
|
|
||||||
// Update object 4 and expect object 3 with same shape to have updated.
|
|
||||||
// @ts-expect-error
|
|
||||||
object4.name = "Updated name from object4"; |
|
||||||
|
|
||||||
await wait(10); |
|
||||||
// @ts-expect-error
|
|
||||||
expect(object3!.name).toBe("Updated name from object4"); |
|
||||||
}); |
|
||||||
} |
|
||||||
}); |
|
@ -1,147 +0,0 @@ |
|||||||
import type { |
|
||||||
ShapeType, |
|
||||||
Shape, |
|
||||||
Predicate, |
|
||||||
Schema, |
|
||||||
} from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
|
|
||||||
interface Triple { |
|
||||||
s: string; |
|
||||||
p: string; |
|
||||||
o: string; |
|
||||||
} |
|
||||||
interface TrackedSubject { |
|
||||||
shape: Shape; |
|
||||||
parentObject?: TrackedSubject; |
|
||||||
valid?: boolean; |
|
||||||
trackedPredicates: Record<string, TrackedPredicate>; |
|
||||||
/** For Sub-objects only */ |
|
||||||
untracked?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
interface TrackedPredicate { |
|
||||||
shape: Predicate; |
|
||||||
currentCardinality: number; |
|
||||||
childSubjects?: TrackedSubject[]; |
|
||||||
} |
|
||||||
|
|
||||||
export function buildObjects(shapeType: ShapeType<any>) { |
|
||||||
//
|
|
||||||
} |
|
||||||
|
|
||||||
export function onTriplesRemoved( |
|
||||||
trackedSubjects: Record<string, TrackedSubject[]>, |
|
||||||
triplesRemoved: string[][], |
|
||||||
shapeType: ShapeType<any> |
|
||||||
) { |
|
||||||
//
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Adds new triples to tracked subjects and creates and returns |
|
||||||
* new tracked (possibly nested) subjects. |
|
||||||
*/ |
|
||||||
export function onTriplesAdded( |
|
||||||
trackedSubjects: Record<string, TrackedSubject[]>, |
|
||||||
triplesAdded: Triple[], |
|
||||||
schema: Schema, |
|
||||||
rootShape?: Shape |
|
||||||
): { newTrackedSubjects: Record<string, TrackedSubject[]> } { |
|
||||||
// Track for secondary iterations.
|
|
||||||
const newTrackedSubjectsCreated: Record<string, TrackedSubject[]> = {}; |
|
||||||
|
|
||||||
for (const triple of triplesAdded) { |
|
||||||
if (!trackedSubjects[triple.s]) { |
|
||||||
// If rootShape is not set, we are applying the triples new nested objects
|
|
||||||
// and we don't create new tracked subjects.
|
|
||||||
if (!rootShape) continue; |
|
||||||
|
|
||||||
// Check if predicate is in root shape type.
|
|
||||||
const matchedPredicate = rootShape.predicates.find((p) => triple.p); |
|
||||||
if (!matchedPredicate) { |
|
||||||
// Nothing to track.
|
|
||||||
continue; |
|
||||||
} |
|
||||||
// The triple should be tracked. Create new tracked subject.
|
|
||||||
const newTrackedSubject: TrackedSubject = { |
|
||||||
shape: rootShape, |
|
||||||
trackedPredicates: { |
|
||||||
[triple.p]: { |
|
||||||
shape: matchedPredicate, |
|
||||||
currentCardinality: 1, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}; |
|
||||||
trackedSubjects[triple.s] = [newTrackedSubject]; |
|
||||||
} else { |
|
||||||
// Add triple to tracked subject(s).
|
|
||||||
// In the case of nested shapes, the same subject can be tracked
|
|
||||||
// in multiple shapes.
|
|
||||||
const trackedSubjectsMatching = trackedSubjects[triple.s]; |
|
||||||
for (const trackedSubject of trackedSubjectsMatching) { |
|
||||||
// Is predicate tracked in this subject's shape?
|
|
||||||
const matchedPredShape = trackedSubject.shape.predicates.find( |
|
||||||
(predShape) => predShape.iri === triple.p |
|
||||||
); |
|
||||||
if (!matchedPredShape) continue; |
|
||||||
// Get or create tracked predicate for tracked shape.
|
|
||||||
let trackedPredicate = |
|
||||||
trackedSubject.trackedPredicates[matchedPredShape?.iri]; |
|
||||||
if (!trackedSubjects) { |
|
||||||
trackedPredicate = { |
|
||||||
currentCardinality: 0, |
|
||||||
shape: matchedPredShape, |
|
||||||
}; |
|
||||||
} |
|
||||||
// Increase cardinality.
|
|
||||||
trackedPredicate.currentCardinality += 1; |
|
||||||
|
|
||||||
// If predicate shape has nested object, track that too.
|
|
||||||
if (trackedPredicate.shape.nestedShape) { |
|
||||||
const newTrackedObject: TrackedSubject = { |
|
||||||
shape: schema[matchedPredShape.nestedShape as string], |
|
||||||
trackedPredicates: {}, |
|
||||||
parentObject: trackedSubject, |
|
||||||
}; |
|
||||||
// Remember for adding to registry and for re-running on nested shapes.
|
|
||||||
const newTracked = newTrackedSubjectsCreated[triple.o]; |
|
||||||
if (!newTracked) |
|
||||||
newTrackedSubjectsCreated[triple.o] = [ |
|
||||||
newTrackedObject, |
|
||||||
]; |
|
||||||
newTracked.push(newTrackedObject); |
|
||||||
|
|
||||||
// Link to parent
|
|
||||||
const childSubjects = trackedPredicate.childSubjects; |
|
||||||
if (childSubjects) childSubjects.push(newTrackedObject); |
|
||||||
else trackedPredicate.childSubjects = [newTrackedObject]; |
|
||||||
} |
|
||||||
|
|
||||||
// TODO: Inform tracked subjects about change
|
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Rerun triples on new tracked subjects created.
|
|
||||||
// Then merge with parent tracked subjects
|
|
||||||
const newNestedSubjectsCreated = onTriplesAdded( |
|
||||||
newTrackedSubjectsCreated, |
|
||||||
triplesAdded, |
|
||||||
schema |
|
||||||
); |
|
||||||
|
|
||||||
// TODO: Is it correct to do this?
|
|
||||||
const ret: Record<string, TrackedSubject[]> = {}; |
|
||||||
for (const subjectIri of Object.keys(newTrackedSubjectsCreated)) { |
|
||||||
ret[subjectIri] = [ |
|
||||||
...newTrackedSubjectsCreated[subjectIri], |
|
||||||
...newNestedSubjectsCreated.newTrackedSubjects[subjectIri], |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
// Update Valid invalid
|
|
||||||
|
|
||||||
return { newTrackedSubjects: ret }; |
|
||||||
} |
|
||||||
|
|
||||||
// Do we have infinite-loop issues?
|
|
@ -1,172 +0,0 @@ |
|||||||
import * as shapeManager from "./shapeManager"; |
|
||||||
import type { WasmConnection, Diff, Scope } from "./types"; |
|
||||||
import type { ShapeType, BaseType } from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
import * as ng from "@nextgraph-monorepo/ng-sdk-js"; |
|
||||||
import type { Person } from "../../shapes/ldo/personShape.typings"; |
|
||||||
import type { Cat } from "../../shapes/ldo/catShape.typings"; |
|
||||||
import type { TestObject } from "../../shapes/ldo/testShape.typings"; |
|
||||||
import updateShape from "./updateShape"; |
|
||||||
import { testShapeSchema } from "../../shapes/ldo/testShape.schema"; |
|
||||||
import { TestObjectShapeType } from "../../shapes/ldo/testShape.shapeTypes"; |
|
||||||
|
|
||||||
// Messages exchanged over the BroadcastChannel("shape-manager")
|
|
||||||
interface WasmMessage { |
|
||||||
type: |
|
||||||
| "Request" |
|
||||||
| "InitialResponse" |
|
||||||
| "FrontendUpdate" |
|
||||||
| "BackendUpdate" |
|
||||||
| "Stop"; |
|
||||||
connectionId: string; |
|
||||||
diff?: Diff; |
|
||||||
shapeType?: ShapeType<any>; |
|
||||||
initialData?: BaseType; |
|
||||||
} |
|
||||||
|
|
||||||
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 BaseType>(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); |
|
||||||
// call WASM ng-sdk-js
|
|
||||||
const { type, connectionId, shapeType } = event.data; |
|
||||||
|
|
||||||
if (type === "Request") { |
|
||||||
ng.orm_update("", "", {}, 1); |
|
||||||
/* unsub = await ng.orm_start(scope, shapeType, session_id, |
|
||||||
async (response) => { |
|
||||||
//console.log("GOT APP RESPONSE", response);
|
|
||||||
if (response.V0.OrmInitial) {
|
|
||||||
|
|
||||||
} else if (response.V0.OrmUpdate) { |
|
||||||
let diff = response.V0.OrmUpdate.diff |
|
||||||
const msg: WasmMessage = { |
|
||||||
type: "BackendUpdate", |
|
||||||
connectionId, |
|
||||||
diff, |
|
||||||
}; |
|
||||||
communicationChannel.postMessage(msg); |
|
||||||
} else if (response.V0.OrmError) { |
|
||||||
|
|
||||||
}
|
|
||||||
*/ |
|
||||||
|
|
||||||
const shapeId = shapeType?.shape; |
|
||||||
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, |
|
||||||
diff, |
|
||||||
}; |
|
||||||
communicationChannel.postMessage(msg); |
|
||||||
}, |
|
||||||
}); |
|
||||||
|
|
||||||
const msg: WasmMessage = { |
|
||||||
type: "InitialResponse", |
|
||||||
connectionId, |
|
||||||
initialData, |
|
||||||
}; |
|
||||||
communicationChannel.postMessage(msg); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (type === "Stop") { |
|
||||||
shapeManager.connections.delete(connectionId); |
|
||||||
// await ng.app_request ( OrmStop )
|
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (type === "FrontendUpdate" && event.data.diff) { |
|
||||||
updateShape(connectionId, event.data.diff); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
console.warn( |
|
||||||
"BACKEND: Unknown message type or missing diff", |
|
||||||
event.data |
|
||||||
); |
|
||||||
} |
|
||||||
); |
|
||||||
|
|
||||||
ng.orm_start("", TestObjectShapeType, 12, (...params: any) => { |
|
||||||
console.log("updates received with params", params); |
|
||||||
}).catch((err) => console.error(err)); |
|
@ -1,10 +0,0 @@ |
|||||||
import type { Diff, ObjectState, WasmConnection } from "./types"; |
|
||||||
|
|
||||||
const connections: Map<WasmConnection["id"], WasmConnection> = new Map(); |
|
||||||
|
|
||||||
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
|
||||||
export function applyDiff(currentState: ObjectState, diff: Diff): ObjectState { |
|
||||||
return JSON.parse(JSON.stringify(diff)); |
|
||||||
} |
|
||||||
|
|
||||||
export { connections }; |
|
@ -1,220 +0,0 @@ |
|||||||
import type { Predicate, Shape, Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
|
|
||||||
/** |
|
||||||
* 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: Shape, |
|
||||||
schema: Schema, |
|
||||||
options?: SparqlBuildOptions |
|
||||||
): string { |
|
||||||
const ctx: BuildContext = { usedVars: new Set<string>() }; |
|
||||||
const prefixes = prefixesToText(options?.prefixes); |
|
||||||
const subject = shape.iri; |
|
||||||
|
|
||||||
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: Predicate, |
|
||||||
subjectVar: string, |
|
||||||
template: string[], |
|
||||||
where: string[], |
|
||||||
postFilters: string[], |
|
||||||
valuesBlocks: string[], |
|
||||||
options?: SparqlBuildOptions |
|
||||||
) { |
|
||||||
const p = `<${pred.iri}>`; |
|
||||||
const objVar = uniqueVar(ctx, pred.readablePredicate); |
|
||||||
|
|
||||||
const triple = `${subjectVar} ${p} ${objTerm} .`; |
|
||||||
|
|
||||||
const isOptional = |
|
||||||
(pred.minCardinality ?? 0) === 0 && |
|
||||||
(options?.includeOptionalForMinZero ?? true); |
|
||||||
|
|
||||||
if (pred.dataTypes === "nested" && pred.nestedShape) { |
|
||||||
template.push(triple); |
|
||||||
const nestedBody: string[] = [triple]; |
|
||||||
const nestedPreds = pred.nestedShape.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.dataTypes === "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 type LiteralKind = |
|
||||||
| "number" |
|
||||||
| "string" |
|
||||||
| "boolean" |
|
||||||
| "nested" |
|
||||||
| "literal"; |
|
||||||
|
|
||||||
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 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; |
|
||||||
} |
|
||||||
|
|
||||||
export default buildConstructQuery; |
|
@ -1,140 +0,0 @@ |
|||||||
import type { Predicate, Shape, Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
|
|
||||||
export const buildConstructQuery = ({ |
|
||||||
schema, |
|
||||||
shapeId, |
|
||||||
}: { |
|
||||||
schema: Schema; |
|
||||||
shapeId: keyof Schema; |
|
||||||
}): string => { |
|
||||||
const rootShape = schema[shapeId]; |
|
||||||
|
|
||||||
const constructStatements: { |
|
||||||
s: string; |
|
||||||
p: string; |
|
||||||
o: string; |
|
||||||
optional: boolean; |
|
||||||
literals: Predicate["literalValue"]; |
|
||||||
}[] = []; |
|
||||||
|
|
||||||
const idToVarName: Record<string, string> = {}; |
|
||||||
const getVarNameFor = (id: string) => { |
|
||||||
const currentName = idToVarName[id]; |
|
||||||
if (currentName) return currentName; |
|
||||||
|
|
||||||
const newVar = `o${Object.entries(idToVarName).length + 1}`; |
|
||||||
idToVarName[id] = newVar; |
|
||||||
return newVar; |
|
||||||
}; |
|
||||||
|
|
||||||
// Create s,p,o records where subject and object var names are mapped to shape or predicate ids.
|
|
||||||
const addTriples = (shape: Shape) => { |
|
||||||
const predicates = shape.predicates; |
|
||||||
const shapeId = shape.iri; |
|
||||||
|
|
||||||
for (const pred of predicates) { |
|
||||||
const subjectVarName = getVarNameFor(shapeId); |
|
||||||
|
|
||||||
if (pred.dataTypes === "nested") { |
|
||||||
if (typeof pred.nestedShape !== "string") |
|
||||||
throw new Error("Nested shapes must be by reference"); |
|
||||||
|
|
||||||
// If a name for this shape was assigned already, it's triples have been added
|
|
||||||
// and we don't have to recurse.
|
|
||||||
const shapeAlreadyRegistered = !!idToVarName[pred.nestedShape]; |
|
||||||
|
|
||||||
const shapeVarName = getVarNameFor(pred.nestedShape); |
|
||||||
|
|
||||||
constructStatements.push({ |
|
||||||
s: `?${subjectVarName}`, |
|
||||||
p: `<${pred.iri}>`, |
|
||||||
o: `?${shapeVarName}`, |
|
||||||
optional: pred.minCardinality < 1, |
|
||||||
literals: pred.literalValue, |
|
||||||
// TODO: eitherOf ?
|
|
||||||
}); |
|
||||||
|
|
||||||
if (!shapeAlreadyRegistered) |
|
||||||
addTriples(schema[pred.nestedShape]); |
|
||||||
} else { |
|
||||||
const objVarName = getVarNameFor( |
|
||||||
shapeId + "__separator__" + pred.iri |
|
||||||
); |
|
||||||
|
|
||||||
constructStatements.push({ |
|
||||||
s: `?${subjectVarName}`, |
|
||||||
p: `<${pred.iri}>`, |
|
||||||
o: `?${objVarName}`, |
|
||||||
optional: pred.minCardinality < 1, |
|
||||||
literals: pred.literalValue, |
|
||||||
// TODO: eitherOf ?
|
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
addTriples(rootShape); |
|
||||||
|
|
||||||
const construct = `CONSTRUCT {
|
|
||||||
${constructStatements.map(({ s, p, o }) => ` ${s} ${p} ${o} .\n`).join("")} }`;
|
|
||||||
|
|
||||||
const statementToWhere = ({ |
|
||||||
s, |
|
||||||
p, |
|
||||||
o, |
|
||||||
optional, |
|
||||||
}: { |
|
||||||
s: string; |
|
||||||
p: string; |
|
||||||
o: string; |
|
||||||
optional: boolean; |
|
||||||
}) => { |
|
||||||
if (optional) return ` OPTIONAL { ${s} ${p} ${o} . }\n`; |
|
||||||
else return ` ${s} ${p} ${o} .\n`; |
|
||||||
}; |
|
||||||
|
|
||||||
const literalToSparqlFormat = ( |
|
||||||
literal: string | number | boolean |
|
||||||
): string => { |
|
||||||
if (typeof literal === "number") return String(literal); |
|
||||||
if (typeof literal === "boolean") return literal ? "true" : "false"; |
|
||||||
if (typeof literal === "string") { |
|
||||||
return isIri(literal) |
|
||||||
? `<${literal}>` |
|
||||||
: `"${escapeString(literal)}"`; |
|
||||||
} |
|
||||||
return `"${String(literal)}"`; |
|
||||||
}; |
|
||||||
|
|
||||||
// Filters for optional values.
|
|
||||||
const filters = constructStatements |
|
||||||
.filter((statement) => statement.literals !== undefined) |
|
||||||
.map((statement) => { |
|
||||||
const vals = arrayOf(statement.literals!); |
|
||||||
if (vals.length === 0) return ""; |
|
||||||
if (vals.length === 1) { |
|
||||||
return ` FILTER(${statement.o} = ${literalToSparqlFormat(vals[0]!)})\n`; |
|
||||||
} |
|
||||||
const list = vals.map(literalToSparqlFormat).join(", "); |
|
||||||
return ` FILTER(${statement.o} IN (${list}))\n`; |
|
||||||
}) |
|
||||||
.join(""); |
|
||||||
|
|
||||||
const where = `WHERE {
|
|
||||||
${constructStatements.map(statementToWhere).join("")} |
|
||||||
${filters} |
|
||||||
}`;
|
|
||||||
|
|
||||||
return `${construct}\n${where}`; |
|
||||||
}; |
|
||||||
|
|
||||||
const arrayOf = <T extends any>(arrayOrLiteral: T | T[]) => { |
|
||||||
if (typeof arrayOrLiteral === "undefined" || arrayOrLiteral === null) |
|
||||||
return []; |
|
||||||
if (Array.isArray(arrayOrLiteral)) return arrayOrLiteral; |
|
||||||
return [arrayOrLiteral]; |
|
||||||
}; |
|
||||||
|
|
||||||
const isIri = (str: string) => /^[a-zA-Z][a-zA-Z0-9+.-]{1,7}:/.test(str); |
|
||||||
|
|
||||||
const escapeString = (str: string) => str.replace(/["\\]/g, "\\$&"); |
|
@ -1,9 +0,0 @@ |
|||||||
import { buildConstructQuery } from "./buildSparqlConstructFromShape.ts"; |
|
||||||
import { testShapeSchema } from "./testShape.schema.ts"; |
|
||||||
|
|
||||||
console.log( |
|
||||||
buildConstructQuery({ |
|
||||||
schema: testShapeSchema, |
|
||||||
shapeId: "http://example.org/TestObject", |
|
||||||
}) |
|
||||||
); |
|
@ -1,129 +0,0 @@ |
|||||||
import type { Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
|
|
||||||
/** |
|
||||||
* ============================================================================= |
|
||||||
* testShapeSchema: Schema for testShape |
|
||||||
* ============================================================================= |
|
||||||
*/ |
|
||||||
export const testShapeSchema: Schema = { |
|
||||||
"http://example.org/TestObject": { |
|
||||||
iri: "http://example.org/TestObject", |
|
||||||
predicates: [ |
|
||||||
{ |
|
||||||
dataTypes: "literal", |
|
||||||
literalValue: ["TestObject"], |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
|
||||||
readablePredicate: "type", |
|
||||||
extra: true, |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "string", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/stringValue", |
|
||||||
readablePredicate: "stringValue", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "number", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/numValue", |
|
||||||
readablePredicate: "numValue", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "boolean", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/boolValue", |
|
||||||
readablePredicate: "boolValue", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "number", |
|
||||||
maxCardinality: -1, |
|
||||||
minCardinality: 0, |
|
||||||
iri: "http://example.org/arrayValue", |
|
||||||
readablePredicate: "arrayValue", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "nested", |
|
||||||
nestedShape: |
|
||||||
"http://example.org/TestObject||http://example.org/objectValue", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/objectValue", |
|
||||||
readablePredicate: "objectValue", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "nested", |
|
||||||
nestedShape: |
|
||||||
"http://example.org/TestObject||http://example.org/anotherObject", |
|
||||||
maxCardinality: -1, |
|
||||||
minCardinality: 0, |
|
||||||
iri: "http://example.org/anotherObject", |
|
||||||
readablePredicate: "anotherObject", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "eitherOf", |
|
||||||
eitherOf: [ |
|
||||||
{ |
|
||||||
valType: "string", |
|
||||||
}, |
|
||||||
{ |
|
||||||
valType: "number", |
|
||||||
}, |
|
||||||
], |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/numOrStr", |
|
||||||
readablePredicate: "numOrStr", |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
"http://example.org/TestObject||http://example.org/objectValue": { |
|
||||||
iri: "http://example.org/TestObject||http://example.org/objectValue", |
|
||||||
predicates: [ |
|
||||||
{ |
|
||||||
dataTypes: "string", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/nestedString", |
|
||||||
readablePredicate: "nestedString", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "number", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/nestedNum", |
|
||||||
readablePredicate: "nestedNum", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "number", |
|
||||||
maxCardinality: -1, |
|
||||||
minCardinality: 0, |
|
||||||
iri: "http://example.org/nestedArray", |
|
||||||
readablePredicate: "nestedArray", |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
"http://example.org/TestObject||http://example.org/anotherObject": { |
|
||||||
iri: "http://example.org/TestObject||http://example.org/anotherObject", |
|
||||||
predicates: [ |
|
||||||
{ |
|
||||||
dataTypes: "string", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/prop1", |
|
||||||
readablePredicate: "prop1", |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataTypes: "number", |
|
||||||
maxCardinality: 1, |
|
||||||
minCardinality: 1, |
|
||||||
iri: "http://example.org/prop2", |
|
||||||
readablePredicate: "prop2", |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
}; |
|
@ -1,18 +0,0 @@ |
|||||||
import type { ShapeType, BaseType } from "@nextgraph-monorepo/ng-shex-orm"; |
|
||||||
import type { Patch } from "@nextgraph-monorepo/ng-signals"; |
|
||||||
|
|
||||||
/** The Scope of a shape request */ |
|
||||||
export type Scope = string | string[]; |
|
||||||
|
|
||||||
/** The diff format used to communicate updates between wasm-land and js-land. */ |
|
||||||
export type Diff = Patch[]; |
|
||||||
|
|
||||||
export type ObjectState = object; |
|
||||||
|
|
||||||
/** A connection established between wasm-land and js-land for subscription of a shape. */ |
|
||||||
export type WasmConnection<T extends BaseType = BaseType> = { |
|
||||||
id: string; |
|
||||||
shape: ShapeType<T>; |
|
||||||
state: ObjectState; |
|
||||||
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void; |
|
||||||
}; |
|
@ -1,22 +0,0 @@ |
|||||||
import * as shapeManager from "./shapeManager"; |
|
||||||
import type { WasmConnection, Diff } from "./types"; |
|
||||||
|
|
||||||
export default async function updateShape( |
|
||||||
connectionId: WasmConnection["id"], |
|
||||||
diff: Diff, |
|
||||||
) { |
|
||||||
const connection = shapeManager.connections.get(connectionId); |
|
||||||
if (!connection) throw new Error("No Connection found."); |
|
||||||
|
|
||||||
console.log("BACKEND: Received update request from ", connectionId); |
|
||||||
|
|
||||||
const newState = shapeManager.applyDiff(connection.state, diff); |
|
||||||
connection.state = newState; |
|
||||||
|
|
||||||
shapeManager.connections.forEach((con) => { |
|
||||||
// if (con.shape == connection.shape) {
|
|
||||||
// con.state = newState;
|
|
||||||
// con.callback(diff, con.id);
|
|
||||||
// }
|
|
||||||
}); |
|
||||||
} |
|
@ -0,0 +1,235 @@ |
|||||||
|
import { describe, test, expect } from "vitest"; |
||||||
|
import { applyDiff, Patch } from "../index.ts"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a patch path string from segments (auto-prefix /) |
||||||
|
*/ |
||||||
|
function p(...segs: (string | number)[]) { |
||||||
|
return "/" + segs.map(String).join("/"); |
||||||
|
} |
||||||
|
|
||||||
|
describe("applyDiff - set operations (primitives)", () => { |
||||||
|
test("add single primitive into new set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", valType: "set", path: p("tags"), value: "a" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.tags).toBeInstanceOf(Set); |
||||||
|
expect([...state.tags]).toEqual(["a"]); |
||||||
|
}); |
||||||
|
test("add multiple primitives into new set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", valType: "set", path: p("nums"), value: [1, 2, 3] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums]).toEqual([1, 2, 3]); |
||||||
|
}); |
||||||
|
test("add primitives merging into existing set", () => { |
||||||
|
const state: any = { nums: new Set([1]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", valType: "set", path: p("nums"), value: [2, 3] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums].sort()).toEqual([1, 2, 3]); |
||||||
|
}); |
||||||
|
test("remove single primitive from set", () => { |
||||||
|
const state: any = { tags: new Set(["a", "b"]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "remove", valType: "set", path: p("tags"), value: "a" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.tags]).toEqual(["b"]); |
||||||
|
}); |
||||||
|
test("remove multiple primitives from set", () => { |
||||||
|
const state: any = { nums: new Set([1, 2, 3, 4]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "remove", valType: "set", path: p("nums"), value: [2, 4] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums].sort()).toEqual([1, 3]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - set operations (object sets)", () => { |
||||||
|
test("add object entries to new object-set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
valType: "set", |
||||||
|
path: p("users"), |
||||||
|
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, |
||||||
|
}, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.users.u1).toEqual({ id: "u1", n: 1 }); |
||||||
|
expect(state.users.u2).toEqual({ id: "u2", n: 2 }); |
||||||
|
}); |
||||||
|
test("merge object entries into existing object-set", () => { |
||||||
|
const state: any = { users: { u1: { id: "u1", n: 1 } } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
valType: "set", |
||||||
|
path: p("users"), |
||||||
|
value: { u2: { id: "u2", n: 2 } }, |
||||||
|
}, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||||
|
}); |
||||||
|
test("remove object entries from object-set", () => { |
||||||
|
const state: any = { users: { u1: {}, u2: {}, u3: {} } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "remove", |
||||||
|
valType: "set", |
||||||
|
path: p("users"), |
||||||
|
value: ["u1", "u3"], |
||||||
|
}, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users)).toEqual(["u2"]); |
||||||
|
}); |
||||||
|
test("adding primitives to existing object-set replaces with Set", () => { |
||||||
|
const state: any = { mixed: { a: {}, b: {} } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", valType: "set", path: p("mixed"), value: [1, 2] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.mixed).toBeInstanceOf(Set); |
||||||
|
expect([...state.mixed]).toEqual([1, 2]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - object & literal operations", () => { |
||||||
|
test("add object (create empty object)", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("address"), valType: "object" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address).toEqual({}); |
||||||
|
}); |
||||||
|
test("add nested object path with ensurePathExists", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("a", "b", "c"), valType: "object" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff, true); |
||||||
|
expect(state.a.b.c).toEqual({}); |
||||||
|
}); |
||||||
|
test("add primitive value", () => { |
||||||
|
const state: any = { address: {} }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("address", "street"), value: "1st" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBe("1st"); |
||||||
|
}); |
||||||
|
test("overwrite primitive value", () => { |
||||||
|
const state: any = { address: { street: "old" } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("address", "street"), value: "new" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBe("new"); |
||||||
|
}); |
||||||
|
test("remove primitive", () => { |
||||||
|
const state: any = { address: { street: "1st", country: "Greece" } }; |
||||||
|
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBeUndefined(); |
||||||
|
expect(state.address.country).toBe("Greece"); |
||||||
|
}); |
||||||
|
test("remove object branch", () => { |
||||||
|
const state: any = { address: { street: "1st" }, other: 1 }; |
||||||
|
const diff: Patch[] = [{ op: "remove", path: p("address") }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address).toBeUndefined(); |
||||||
|
expect(state.other).toBe(1); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - multiple mixed patches in a single diff", () => { |
||||||
|
test("sequence of mixed set/object/literal add & remove", () => { |
||||||
|
const state: any = { |
||||||
|
users: { u1: { id: "u1" } }, |
||||||
|
tags: new Set(["old"]), |
||||||
|
}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
valType: "set", |
||||||
|
path: p("users"), |
||||||
|
value: { u2: { id: "u2" } }, |
||||||
|
}, |
||||||
|
{ op: "add", path: p("profile"), valType: "object" }, |
||||||
|
{ op: "add", path: p("profile", "name"), value: "Alice" }, |
||||||
|
{ op: "add", valType: "set", path: p("tags"), value: ["new"] }, |
||||||
|
{ op: "remove", valType: "set", path: p("tags"), value: "old" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||||
|
expect(state.profile.name).toBe("Alice"); |
||||||
|
expect([...state.tags]).toEqual(["new"]); |
||||||
|
}); |
||||||
|
|
||||||
|
test("complex nested path creation and mutations with ensurePathExists", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("a", "b"), valType: "object" }, |
||||||
|
{ op: "add", path: p("a", "b", "c"), value: 1 }, |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
valType: "set", |
||||||
|
path: p("a", "nums"), |
||||||
|
value: [1, 2, 3], |
||||||
|
}, |
||||||
|
{ op: "remove", valType: "set", path: p("a", "nums"), value: 2 }, |
||||||
|
{ op: "add", path: p("a", "b", "d"), value: 2 }, |
||||||
|
{ op: "remove", path: p("a", "b", "c") }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff, true); |
||||||
|
expect(state.a.b.c).toBeUndefined(); |
||||||
|
expect(state.a.b.d).toBe(2); |
||||||
|
expect(state.a.nums).toBeInstanceOf(Set); |
||||||
|
expect([...state.a.nums].sort()).toEqual([1, 3]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - ignored / invalid scenarios", () => { |
||||||
|
test("skip patch with non-leading slash path", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: "address/street", value: "x" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||||
|
applyDiff(state, diff, false); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - ignored / invalid scenarios", () => { |
||||||
|
test("skip patch with non-leading slash path", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: "address/street", value: "x" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||||
|
applyDiff(state, diff, false); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue