diff --git a/packages/connected/src/trackingProxy/TrackingProxyContext.ts b/packages/connected/src/trackingProxy/TrackingProxyContext.ts new file mode 100644 index 0000000..16aa8cf --- /dev/null +++ b/packages/connected/src/trackingProxy/TrackingProxyContext.ts @@ -0,0 +1,72 @@ +import type { + ProxyContextOptions, + SubjectProxy, + SetProxy, +} from "@ldo/jsonld-dataset-proxy"; +import { ProxyContext } from "@ldo/jsonld-dataset-proxy"; +import type { QuadMatch } from "@ldo/rdf-utils"; +import type { + nodeEventListener, + SubscribableDataset, +} from "@ldo/subscribable-dataset"; +import type { BlankNode, NamedNode, Quad } from "@rdfjs/types"; +import { createTrackingSubjectProxy } from "./TrackingSubjectProxy"; +import { createTrackingSetProxy } from "./TrackingSetProxy"; + +/** + * @internal + * Options to be passed to the tracking proxy + */ +export interface TrackingProxyContextOptions extends ProxyContextOptions { + dataset: SubscribableDataset; +} + +/** + * @internal + * A listener that gets triggered whenever there's an update + */ + +/** + * @internal + * This proxy exists to ensure react components rerender at the right time. It + * keeps track of every key accessed in a Linked Data Object and only when the + * dataset is updated with that key does it rerender the react component. + */ +export class TrackingProxyContext extends ProxyContext { + private listener: nodeEventListener; + private subscribableDataset: SubscribableDataset; + + constructor( + options: TrackingProxyContextOptions, + listener: nodeEventListener, + ) { + super(options); + this.subscribableDataset = options.dataset; + this.listener = listener; + } + + // Adds the listener to the subscribable dataset while ensuring deduping of the listener + public addListener(eventName: QuadMatch) { + const listeners = this.subscribableDataset.listeners(eventName); + if (!listeners.includes(this.listener)) { + this.subscribableDataset.on(eventName, this.listener); + } + } + + protected createNewSubjectProxy(node: NamedNode | BlankNode): SubjectProxy { + return createTrackingSubjectProxy(this, node); + } + + protected createNewSetProxy( + quadMatch: QuadMatch, + isSubjectOriented?: boolean, + isLangStringSet?: boolean, + ): SetProxy { + return createTrackingSetProxy( + this, + quadMatch, + isSubjectOriented, + isLangStringSet, + ); + } +} diff --git a/packages/connected/src/trackingProxy/TrackingSetProxy.ts b/packages/connected/src/trackingProxy/TrackingSetProxy.ts new file mode 100644 index 0000000..5f141f5 --- /dev/null +++ b/packages/connected/src/trackingProxy/TrackingSetProxy.ts @@ -0,0 +1,62 @@ +import { createNewSetProxy, type SetProxy } from "@ldo/jsonld-dataset-proxy"; +import type { TrackingProxyContext } from "./TrackingProxyContext"; +import type { QuadMatch } from "@ldo/rdf-utils"; + +/** + * @internal + * + * Creates a tracking proxy for a set, a proxy that tracks the fields that have + * been accessed. + */ +export function createTrackingSetProxy( + proxyContext: TrackingProxyContext, + quadMatch: QuadMatch, + isSubjectOriented?: boolean, + isLangStringSet?: boolean, +): SetProxy { + const baseSetProxy = createNewSetProxy( + quadMatch, + isSubjectOriented ?? false, + proxyContext, + isLangStringSet, + ); + + return new Proxy(baseSetProxy, { + get: (target: SetProxy, key: string | symbol, receiver) => { + if (trackingMethods.has(key)) { + proxyContext.addListener(quadMatch); + } else if (disallowedMethods.has(key)) { + console.warn( + "You've attempted to modify a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.", + ); + } + return Reflect.get(target, key, receiver); + }, + }); +} + +const trackingMethods = new Set([ + "has", + "size", + "entries", + "keys", + "values", + Symbol.iterator, + "every", + "every", + "some", + "forEach", + "map", + "reduce", + "toArray", + "toJSON", + "difference", + "intersection", + "isDisjointFrom", + "isSubsetOf", + "isSupersetOf", + "symmetricDifference", + "union", +]); + +const disallowedMethods = new Set(["add", "clear", "delete"]); diff --git a/packages/connected/src/trackingProxy/TrackingSubjectProxy.ts b/packages/connected/src/trackingProxy/TrackingSubjectProxy.ts new file mode 100644 index 0000000..74154bf --- /dev/null +++ b/packages/connected/src/trackingProxy/TrackingSubjectProxy.ts @@ -0,0 +1,49 @@ +import type { SubjectProxyTarget } from "@ldo/jsonld-dataset-proxy"; +import { + createSubjectHandler, + type SubjectProxy, +} from "@ldo/jsonld-dataset-proxy"; +import type { BlankNode, NamedNode } from "@rdfjs/types"; +import type { TrackingProxyContext } from "./TrackingProxyContext"; +import { namedNode } from "@rdfjs/data-model"; + +/** + * @internal + * + * Creates a tracking proxy for a single value, a proxy that tracks the fields + * that have been accessed. + */ +export function createTrackingSubjectProxy( + proxyContext: TrackingProxyContext, + node: NamedNode | BlankNode, +): SubjectProxy { + const baseHandler = createSubjectHandler(proxyContext); + const oldGetFunction = baseHandler.get; + const newGetFunction: ProxyHandler["get"] = ( + target: SubjectProxyTarget, + key: string | symbol, + receiver, + ) => { + const subject = target["@id"]; + const rdfTypes = proxyContext.getRdfType(subject); + if (typeof key === "symbol") { + // Do Nothing + } else if (key === "@id") { + proxyContext.addListener([subject, null, null, null]); + } else if (!proxyContext.contextUtil.isSet(key, rdfTypes)) { + const predicate = namedNode( + proxyContext.contextUtil.keyToIri(key, rdfTypes), + ); + proxyContext.addListener([subject, predicate, null, null]); + } + return oldGetFunction && oldGetFunction(target, key, receiver); + }; + baseHandler.get = newGetFunction; + baseHandler.set = () => { + console.warn( + "You've attempted to set a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.", + ); + return true; + }; + return new Proxy({ "@id": node }, baseHandler) as unknown as SubjectProxy; +} diff --git a/packages/connected/src/trackingProxy/createTrackingProxy.ts b/packages/connected/src/trackingProxy/createTrackingProxy.ts new file mode 100644 index 0000000..8fb2059 --- /dev/null +++ b/packages/connected/src/trackingProxy/createTrackingProxy.ts @@ -0,0 +1,42 @@ +import { + ContextUtil, + JsonldDatasetProxyBuilder, +} from "@ldo/jsonld-dataset-proxy"; +import { LdoBuilder } from "@ldo/ldo"; +import type { LdoBase, LdoDataset, ShapeType } from "@ldo/ldo"; +import { TrackingProxyContext } from "./TrackingProxyContext"; +import { defaultGraph } from "@rdfjs/data-model"; +import type { nodeEventListener } from "@ldo/subscribable-dataset"; +import type { Quad } from "@rdfjs/types"; + +/** + * @internal + * Creates a Linked Data Object builder that when creating linked data objects + * it tracks when something that was read from it is updated and triggers some + * action based on that. + */ +export function createTrackingProxyBuilder( + dataset: LdoDataset, + shapeType: ShapeType, + onUpdate: nodeEventListener, +): LdoBuilder { + // Remove all current subscriptions + // dataset.removeListenerFromAllEvents(onUpdate); + + // Rebuild the LdoBuilder from scratch to inject TrackingProxyContext + const contextUtil = new ContextUtil(shapeType.context); + const proxyContext = new TrackingProxyContext( + { + dataset, + contextUtil, + writeGraphs: [defaultGraph()], + languageOrdering: ["none", "en", "other"], + }, + onUpdate, + ); + const builder = new LdoBuilder( + new JsonldDatasetProxyBuilder(proxyContext), + shapeType, + ); + return builder; +} diff --git a/packages/connected/test/ErrorResult.test.ts b/packages/connected/test/ErrorResult.test.ts index 273f541..873dcd9 100644 --- a/packages/connected/test/ErrorResult.test.ts +++ b/packages/connected/test/ErrorResult.test.ts @@ -5,7 +5,7 @@ import { UnexpectedResourceError, } from "../src/results/error/ErrorResult"; import { InvalidUriError } from "../src/results/error/InvalidUriError"; -import { MockResouce } from "./MockResource"; +import { MockResouce } from "./mocks/MockResource"; const mockResource = new MockResouce("https://example.com/"); diff --git a/packages/connected/test/MockResource.ts b/packages/connected/test/mocks/MockResource.ts similarity index 89% rename from packages/connected/test/MockResource.ts rename to packages/connected/test/mocks/MockResource.ts index e84b0b3..a734904 100644 --- a/packages/connected/test/MockResource.ts +++ b/packages/connected/test/mocks/MockResource.ts @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import EventEmitter from "events"; -import type { ResourceError } from "../src"; +import type { ResourceError } from "../../src"; import { Unfetched, type ConnectedResult, type Resource, type ResourceEventEmitter, -} from "../src"; +} from "../../src"; import type { DatasetChanges } from "@ldo/rdf-utils"; -import type { ReadSuccess } from "../src/results/success/ReadSuccess"; -import type { UpdateSuccess } from "../src/results/success/UpdateSuccess"; +import type { ReadSuccess } from "../../src/results/success/ReadSuccess"; +import type { UpdateSuccess } from "../../src/results/success/UpdateSuccess"; export class MockResouce extends (EventEmitter as new () => ResourceEventEmitter)