diff --git a/package-lock.json b/package-lock.json index 549b9d7..173d83b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6251,6 +6251,10 @@ "resolved": "packages/solid-react", "link": true }, + "node_modules/@ldo/solid-type-index": { + "resolved": "packages/solid-type-index", + "link": true + }, "node_modules/@ldo/subscribable-dataset": { "resolved": "packages/subscribable-dataset", "link": true @@ -29728,6 +29732,106 @@ "node": ">=4.2.0" } }, + "packages/solid-type-index": { + "name": "@ldo/solid-type-index", + "version": "0.0.1-alpha.28", + "license": "MIT", + "dependencies": { + "@ldo/solid": "^0.0.1-alpha.28", + "@ldo/solid-react": "^0.0.1-alpha.28", + "uuid": "^11.0.5" + }, + "devDependencies": { + "@ldo/rdf-utils": "^0.0.1-alpha.24", + "@rdfjs/types": "^1.0.1", + "@testing-library/react": "^14.1.2", + "@types/jest": "^27.0.3", + "@types/uuid": "^10.0.0", + "jest-environment-jsdom": "^27.0.0", + "start-server-and-test": "^2.0.3", + "ts-jest": "^27.1.2", + "ts-node": "^10.9.2" + } + }, + "packages/solid-type-index/node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "packages/solid-type-index/node_modules/ts-jest": { + "version": "27.1.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.5.tgz", + "integrity": "sha512-Xv6jBQPoBEvBq/5i2TeSG9tt/nqkbpcurrEG1b+2yfBrcJelOZF9Ml6dmyMh7bcW9JyFbRYpR5rxROSlBLTZHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^27.0.0", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@types/jest": "^27.0.0", + "babel-jest": ">=27.0.0 <28", + "jest": "^27.0.0", + "typescript": ">=3.8 <5.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "packages/solid-type-index/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "packages/solid-type-index/node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/solid/node_modules/ts-jest": { "version": "27.1.5", "dev": true, diff --git a/packages/cli/src/templates/context.ejs b/packages/cli/src/templates/context.ejs index ae02ec0..e70eb97 100644 --- a/packages/cli/src/templates/context.ejs +++ b/packages/cli/src/templates/context.ejs @@ -1,8 +1,8 @@ -import { ContextDefinition } from "jsonld"; +import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; /** * ============================================================================= * <%- fileName %>Context: JSONLD Context for <%- fileName %> * ============================================================================= */ -export const <%- fileName %>Context: ContextDefinition = <%- context %>; +export const <%- fileName %>Context: LdoJsonldContext = <%- context %>; diff --git a/packages/ldo/src/index.ts b/packages/ldo/src/index.ts index 77f5250..a951c44 100644 --- a/packages/ldo/src/index.ts +++ b/packages/ldo/src/index.ts @@ -7,3 +7,4 @@ export * from "./LdoBuilder"; export * from "./createLdoDataset"; import type { LdoBase as LdoBaseImport } from "./util"; export type LdoBase = LdoBaseImport; +export * from "./types"; diff --git a/packages/schema-converter-shex/src/context/JsonLdContextBuilder.ts b/packages/schema-converter-shex/src/context/JsonLdContextBuilder.ts index fe28d73..1c67445 100644 --- a/packages/schema-converter-shex/src/context/JsonLdContextBuilder.ts +++ b/packages/schema-converter-shex/src/context/JsonLdContextBuilder.ts @@ -1,5 +1,6 @@ import type { Annotation } from "shexj"; -import type { ContextDefinition, ExpandedTermDefinition } from "jsonld"; +import type { ExpandedTermDefinition } from "jsonld"; +import type { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; /** * Name functions @@ -184,8 +185,8 @@ export class JsonLdContextBuilder { } } - generateJsonldContext(): ContextDefinition { - const contextDefnition: ContextDefinition = {}; + generateJsonldContext(): LdoJsonldContext { + const contextDefnition: LdoJsonldContext = {}; const namesMap = this.generateNames(); Object.entries(namesMap).forEach(([iri, name]) => { if (this.iriTypes[iri]) { diff --git a/packages/solid-react/src/useResource.ts b/packages/solid-react/src/useResource.ts index fb0d18a..fb3fffb 100644 --- a/packages/solid-react/src/useResource.ts +++ b/packages/solid-react/src/useResource.ts @@ -40,6 +40,7 @@ export function useResource( options?: UseResourceOptions, ): Leaf | Container | undefined { const { getResource } = useLdo(); + const subscriptionIdRef = useRef(); // Get the resource const resource = useMemo(() => { @@ -65,12 +66,15 @@ export function useResource( useEffect(() => { if (options?.subscribe) { - resource?.subscribeToNotifications(); - } else { - resource?.unsubscribeFromNotifications(); + resource + ?.subscribeToNotifications() + .then((subscriptionId) => (subscriptionIdRef.current = subscriptionId)); + } else if (subscriptionIdRef.current) { + resource?.unsubscribeFromNotifications(subscriptionIdRef.current); } return () => { - resource?.unsubscribeFromNotifications(); + if (subscriptionIdRef.current) + resource?.unsubscribeFromNotifications(subscriptionIdRef.current); }; }, [resource, options?.subscribe]); diff --git a/packages/solid-react/src/useSubscribeToResource.ts b/packages/solid-react/src/useSubscribeToResource.ts new file mode 100644 index 0000000..d6dc8b0 --- /dev/null +++ b/packages/solid-react/src/useSubscribeToResource.ts @@ -0,0 +1,52 @@ +import { useLdo } from "./SolidLdoProvider"; +import { useEffect, useRef } from "react"; + +export function useSubscribeToResource(...uris: string[]): void { + const { dataset } = useLdo(); + const currentlySubscribed = useRef>({}); + useEffect(() => { + const resources = uris.map((uri) => dataset.getResource(uri)); + const previousSubscriptions = { ...currentlySubscribed.current }; + Promise.all( + resources.map(async (resource) => { + if (!previousSubscriptions[resource.uri]) { + // Prevent multiple triggers from created subscriptions while waiting + // for connection + currentlySubscribed.current[resource.uri] = "AWAITING"; + // Read and subscribe + await resource.readIfUnfetched(); + currentlySubscribed.current[resource.uri] = + await resource.subscribeToNotifications(); + } else { + delete previousSubscriptions[resource.uri]; + } + }), + ).then(async () => { + // Unsubscribe from all remaining previous subscriptions + await Promise.all( + Object.entries(previousSubscriptions).map( + async ([resourceUri, subscriptionId]) => { + // Unsubscribe + delete currentlySubscribed.current[resourceUri]; + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }); + }, [uris]); + + // Cleanup Subscriptions + useEffect(() => { + return () => { + Promise.all( + Object.entries(currentlySubscribed.current).map( + async ([resourceUri, subscriptionId]) => { + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }; + }, []); +} diff --git a/packages/solid-react/test/Integration.test.tsx b/packages/solid-react/test/Integration.test.tsx index d4d47dd..fbeb8cd 100644 --- a/packages/solid-react/test/Integration.test.tsx +++ b/packages/solid-react/test/Integration.test.tsx @@ -16,6 +16,7 @@ import type { PostSh } from "./.ldo/post.typings"; import { useSubject } from "../src/useSubject"; import { useMatchSubject } from "../src/useMatchSubject"; import { useMatchObject } from "../src/useMatchObject"; +import { useSubscribeToResource } from "../src/useSubscribeToResource"; // Use an increased timeout, since the CSS server takes too much setup time. jest.setTimeout(40_000); @@ -372,7 +373,10 @@ describe("Integration Tests", () => { it("rerenders when asked to subscribe to a resource", async () => { const NotificationTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI, { subscribe: true }); + const [isSubscribed, setIsSubscribed] = useState(true); + const resource = useResource(SAMPLE_DATA_URI, { + subscribe: isSubscribed, + }); const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); const addPublisher = useCallback(async () => { @@ -389,12 +393,16 @@ describe("Integration Tests", () => { return (
+

+ {resource.isSubscribedToNotifications().toString()} +

    {post.publisher.map((publisher) => { return
  • {publisher["@id"]}
  • ; })}
+
); }; @@ -404,15 +412,17 @@ describe("Integration Tests", () => { , ); - const list = await screen.findByRole("list"); - expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1"); - expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); - // Wait for subscription to connect await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); }); + const list = await screen.findByRole("list"); + expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1"); + expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); + const resourceP = await screen.findByRole("resource"); + expect(resourceP.innerHTML).toBe("true"); + // Click button to add a publisher await fireEvent.click(screen.getByText("Add Publisher")); await screen.findByText("https://example.com/Publisher3"); @@ -423,11 +433,11 @@ describe("Integration Tests", () => { "https://example.com/Publisher3", ); - unmount(); + await fireEvent.click(screen.getByText("Unsubscribe")); + const resourcePUpdated = await screen.findByRole("resource"); + expect(resourcePUpdated.innerHTML).toBe("false"); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }); + unmount(); }); }); @@ -510,4 +520,118 @@ describe("Integration Tests", () => { expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); }); }); + + /** + * =========================================================================== + * useSubscribeToResource + * =========================================================================== + */ + describe("useSubscribeToResource", () => { + it("handles useSubscribeToResource", async () => { + const NotificationTest: FunctionComponent = () => { + const [subscribedUris, setSubScribedUris] = useState([ + SAMPLE_DATA_URI, + ]); + useSubscribeToResource(...subscribedUris); + const resource1 = useResource(SAMPLE_DATA_URI); + const resource2 = useResource(SAMPLE_BINARY_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + + const addPublisher = useCallback(async () => { + await fetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: `INSERT DATA { <${SAMPLE_DATA_URI}#Post1> . }`, + headers: { + "Content-Type": "application/sparql-update", + }, + }); + }, []); + + if (resource1.isLoading() || resource2.isLoading()) + return

Loading

; + + return ( +
+

+ {resource1.isSubscribedToNotifications().toString()} +

+

+ {resource2.isSubscribedToNotifications().toString()} +

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+ + + +
+ ); + }; + const { unmount } = render( + + + , + ); + + const preResource1P = await screen.findByRole("resource1"); + expect(preResource1P.innerHTML).toBe("false"); + + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const list = await screen.findByRole("list"); + expect(list.children[0].innerHTML).toBe("https://example.com/Publisher1"); + expect(list.children[1].innerHTML).toBe("https://example.com/Publisher2"); + const resource1P = await screen.findByRole("resource1"); + expect(resource1P.innerHTML).toBe("true"); + const resource2P = await screen.findByRole("resource2"); + expect(resource2P.innerHTML).toBe("false"); + + // Click button to add a publisher + await fireEvent.click(screen.getByText("Add Publisher")); + await screen.findByText("https://example.com/Publisher3"); + + // Verify the new publisher is in the list + const updatedList = await screen.findByRole("list"); + expect(updatedList.children[2].innerHTML).toBe( + "https://example.com/Publisher3", + ); + + await fireEvent.click(screen.getByText("Subscribe More")); + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const resource1PUpdated = await screen.findByRole("resource1"); + expect(resource1PUpdated.innerHTML).toBe("true"); + const resource2PUpdated = await screen.findByRole("resource2"); + expect(resource2PUpdated.innerHTML).toBe("true"); + + await fireEvent.click(screen.getByText("Subscribe Less")); + // Wait for subscription to connect + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + const resource1PUpdatedAgain = await screen.findByRole("resource1"); + expect(resource1PUpdatedAgain.innerHTML).toBe("false"); + const resource2PUpdatedAgain = await screen.findByRole("resource2"); + expect(resource2PUpdatedAgain.innerHTML).toBe("true"); + + unmount(); + }); + }); }); diff --git a/packages/solid-react/test/setUpServer.ts b/packages/solid-react/test/setUpServer.ts index 61be170..b3a54ee 100644 --- a/packages/solid-react/test/setUpServer.ts +++ b/packages/solid-react/test/setUpServer.ts @@ -4,6 +4,7 @@ import fetch from "cross-fetch"; export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3001/"; export const ROOT_ROUTE = process.env.ROOT_CONTAINER || "example/"; export const ROOT_CONTAINER = `${SERVER_DOMAIN}${ROOT_ROUTE}`; +export const WEB_ID = `${SERVER_DOMAIN}${ROOT_ROUTE}profile/card#me`; export const TEST_CONTAINER_SLUG = "test_ldo/"; export const TEST_CONTAINER_URI = `${ROOT_CONTAINER}${TEST_CONTAINER_SLUG}` as ContainerUri; diff --git a/packages/solid-type-index/.eslintrc b/packages/solid-type-index/.eslintrc new file mode 100644 index 0000000..83c51a9 --- /dev/null +++ b/packages/solid-type-index/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["../../.eslintrc"] +} \ No newline at end of file diff --git a/packages/solid-type-index/.gitignore b/packages/solid-type-index/.gitignore new file mode 100644 index 0000000..0c32b1f --- /dev/null +++ b/packages/solid-type-index/.gitignore @@ -0,0 +1 @@ +test/test-server/data \ No newline at end of file diff --git a/packages/solid-type-index/LICENSE.txt b/packages/solid-type-index/LICENSE.txt new file mode 100644 index 0000000..b87e67e --- /dev/null +++ b/packages/solid-type-index/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Jackson Morgan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/solid-type-index/README.md b/packages/solid-type-index/README.md new file mode 100644 index 0000000..f15da3e --- /dev/null +++ b/packages/solid-type-index/README.md @@ -0,0 +1,49 @@ +# @ldo/solid-type-index + +A library to handle [type indexes](https://solid.github.io/type-indexes/index.html) with [LDO](https://ldo.js.org). + +## Installation + +``` +npm i @ldo/solid-type-index @ldo/solid +``` + +## Usage + + +```typescript +import { initTypeIndex } from "@ldo/solid-type-index"; +import { createSolidLdoDataset } from "@ldo/solid"; + +async function main() { + const myWebId = "https://example.com/profile/card#me"; + const solidLdoDataset = createSolidLodDataset(); + + // Initialize a type index for a webId in case it isn't initialized + await initTypeIndex(myWebId, { solidLdoDataset }); + + // Get Type Registrations + const typeRegistrations = await getTypeRegistrations(WEB_ID, { + solidLdoDataset, + }); + + // Get Instance Uris (the URIs for resources that contain an instance of a + // class) + const bookmarkUris: string[] = await getInstanceUris( + "http://www.w3.org/2002/01/bookmark#Bookmark", + typeRegistrations, + { solidLdoDataset } + ); + +} +main(); +``` + +## Sponsorship +This project was made possible by a grant from NGI Zero Entrust via nlnet. Learn more on the [NLnet project page](https://nlnet.nl/project/SolidUsableApps/). + +[nlnet foundation logo](https://nlnet.nl/) +[NGI Zero Entrust Logo](https://nlnet.nl/) + +## Liscense +MIT diff --git a/packages/solid-type-index/jest.config.js b/packages/solid-type-index/jest.config.js new file mode 100644 index 0000000..4275a3f --- /dev/null +++ b/packages/solid-type-index/jest.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("../../jest.config.js"); +module.exports = { + ...sharedConfig, + rootDir: "./", + testEnvironment: "jsdom", +}; diff --git a/packages/solid-type-index/jest.setup.ts b/packages/solid-type-index/jest.setup.ts new file mode 100644 index 0000000..22eed5f --- /dev/null +++ b/packages/solid-type-index/jest.setup.ts @@ -0,0 +1,2 @@ +import "@inrupt/jest-jsdom-polyfills"; +globalThis.fetch = async () => new Response(); diff --git a/packages/solid-type-index/package.json b/packages/solid-type-index/package.json new file mode 100644 index 0000000..e6a7c93 --- /dev/null +++ b/packages/solid-type-index/package.json @@ -0,0 +1,52 @@ +{ + "name": "@ldo/solid-type-index", + "version": "0.0.1-alpha.28", + "description": "Solid Type Index support for LDO", + "main": "dist/index.js", + "scripts": { + "build": "tsc --project tsconfig.build.json", + "watch": "tsc --watch", + "test": "npm run test:integration", + "test:watch": "jest --watch", + "prepublishOnly": "npm run test && npm run build", + "build:ldo": "ldo build --input src/.shapes --output src/.ldo", + "lint": "eslint src/** --fix --no-error-on-unmatched-pattern", + "test:integration": "start-server-and-test start-test-server http://localhost:3003 start-integration-test", + "start-test-server": "ts-node ./test/test-server/runServer.ts", + "start-integration-test": "jest --coverage" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/o-development/ldobjects.git" + }, + "author": "Jackson Morgan", + "license": "MIT", + "bugs": { + "url": "https://github.com/o-development/ldobjects/issues" + }, + "homepage": "https://github.com/o-development/ldobjects/tree/main/packages/solid-react#readme", + "devDependencies": { + "@ldo/rdf-utils": "^0.0.1-alpha.24", + "@rdfjs/types": "^1.0.1", + "@testing-library/react": "^14.1.2", + "@types/jest": "^27.0.3", + "@types/uuid": "^10.0.0", + "jest-environment-jsdom": "^27.0.0", + "start-server-and-test": "^2.0.3", + "ts-jest": "^27.1.2", + "ts-node": "^10.9.2" + }, + "dependencies": { + "@ldo/solid": "^0.0.1-alpha.28", + "@ldo/solid-react": "^0.0.1-alpha.28", + "uuid": "^11.0.5" + }, + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public" + }, + "gitHead": "c63f055aab22155b60a5fdee4172979b9c287dfa" +} diff --git a/packages/solid-type-index/src/.ldo/profile.context.ts b/packages/solid-type-index/src/.ldo/profile.context.ts new file mode 100644 index 0000000..e1e31a2 --- /dev/null +++ b/packages/solid-type-index/src/.ldo/profile.context.ts @@ -0,0 +1,19 @@ +import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; + +/** + * ============================================================================= + * profileContext: JSONLD Context for profile + * ============================================================================= + */ +export const profileContext: LdoJsonldContext = { + privateTypeIndex: { + "@id": "http://www.w3.org/ns/solid/terms#privateTypeIndex", + "@type": "@id", + "@isCollection": true, + }, + publicTypeIndex: { + "@id": "http://www.w3.org/ns/solid/terms#publicTypeIndex", + "@type": "@id", + "@isCollection": true, + }, +}; diff --git a/packages/solid-type-index/src/.ldo/profile.schema.ts b/packages/solid-type-index/src/.ldo/profile.schema.ts new file mode 100644 index 0000000..20c665f --- /dev/null +++ b/packages/solid-type-index/src/.ldo/profile.schema.ts @@ -0,0 +1,64 @@ +import { Schema } from "shexj"; + +/** + * ============================================================================= + * profileSchema: ShexJ Schema for profile + * ============================================================================= + */ +export const profileSchema: Schema = { + type: "Schema", + shapes: [ + { + id: "https://shaperepo.com/schemas/solidProfile#TypeIndexProfile", + type: "ShapeDecl", + shapeExpr: { + type: "Shape", + expression: { + type: "EachOf", + expressions: [ + { + type: "TripleConstraint", + predicate: "http://www.w3.org/ns/solid/terms#privateTypeIndex", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: -1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A registry of all types used on the user's Pod (for private access only)", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://www.w3.org/ns/solid/terms#publicTypeIndex", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: -1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A registry of all types used on the user's Pod (for public access)", + }, + }, + ], + }, + ], + }, + }, + }, + ], +}; diff --git a/packages/solid-type-index/src/.ldo/profile.shapeTypes.ts b/packages/solid-type-index/src/.ldo/profile.shapeTypes.ts new file mode 100644 index 0000000..567e96a --- /dev/null +++ b/packages/solid-type-index/src/.ldo/profile.shapeTypes.ts @@ -0,0 +1,19 @@ +import { ShapeType } from "@ldo/ldo"; +import { profileSchema } from "./profile.schema"; +import { profileContext } from "./profile.context"; +import { TypeIndexProfile } from "./profile.typings"; + +/** + * ============================================================================= + * LDO ShapeTypes profile + * ============================================================================= + */ + +/** + * TypeIndexProfile ShapeType + */ +export const TypeIndexProfileShapeType: ShapeType = { + schema: profileSchema, + shape: "https://shaperepo.com/schemas/solidProfile#TypeIndexProfile", + context: profileContext, +}; diff --git a/packages/solid-type-index/src/.ldo/profile.typings.ts b/packages/solid-type-index/src/.ldo/profile.typings.ts new file mode 100644 index 0000000..bca3f3b --- /dev/null +++ b/packages/solid-type-index/src/.ldo/profile.typings.ts @@ -0,0 +1,27 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * Typescript Typings for profile + * ============================================================================= + */ + +/** + * TypeIndexProfile Type + */ +export interface TypeIndexProfile { + "@id"?: string; + "@context"?: ContextDefinition; + /** + * A registry of all types used on the user's Pod (for private access only) + */ + privateTypeIndex?: { + "@id": string; + }[]; + /** + * A registry of all types used on the user's Pod (for public access) + */ + publicTypeIndex?: { + "@id": string; + }[]; +} diff --git a/packages/solid-type-index/src/.ldo/typeIndex.context.ts b/packages/solid-type-index/src/.ldo/typeIndex.context.ts new file mode 100644 index 0000000..e469629 --- /dev/null +++ b/packages/solid-type-index/src/.ldo/typeIndex.context.ts @@ -0,0 +1,50 @@ +import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; + +/** + * ============================================================================= + * typeIndexContext: JSONLD Context for typeIndex + * ============================================================================= + */ +export const typeIndexContext: LdoJsonldContext = { + type: { + "@id": "@type" + }, + TypeIndex: { + "@id": "http://www.w3.org/ns/solid/terms#TypeIndex", + "@context": { + type: { + "@id": "@type", + }, + }, + }, + ListedDocument: { + "@id": "http://www.w3.org/ns/solid/terms#ListedDocument", + "@context": { + type: { + "@id": "@type", + }, + }, + }, + TypeRegistration: { + "@id": "http://www.w3.org/ns/solid/terms#TypeRegistration", + "@context": { + type: { + "@id": "@type", + }, + forClass: { + "@id": "http://www.w3.org/ns/solid/terms#forClass", + "@type": "@id", + }, + instance: { + "@id": "http://www.w3.org/ns/solid/terms#instance", + "@type": "@id", + "@isCollection": true, + }, + instanceContainer: { + "@id": "http://www.w3.org/ns/solid/terms#instanceContainer", + "@type": "@id", + "@isCollection": true, + }, + }, + }, +}; diff --git a/packages/solid-type-index/src/.ldo/typeIndex.schema.ts b/packages/solid-type-index/src/.ldo/typeIndex.schema.ts new file mode 100644 index 0000000..5d0c442 --- /dev/null +++ b/packages/solid-type-index/src/.ldo/typeIndex.schema.ts @@ -0,0 +1,144 @@ +import { Schema } from "shexj"; + +/** + * ============================================================================= + * typeIndexSchema: ShexJ Schema for typeIndex + * ============================================================================= + */ +export const typeIndexSchema: Schema = { + type: "Schema", + shapes: [ + { + id: "https://shaperepo.com/schemas/solidProfile#TypeIndexDocument", + 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: ["http://www.w3.org/ns/solid/terms#TypeIndex"], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "Defines the node as a TypeIndex", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + valueExpr: { + type: "NodeConstraint", + values: ["http://www.w3.org/ns/solid/terms#ListedDocument"], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "Defines the node as a Listed Document", + }, + }, + ], + }, + ], + }, + extra: ["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"], + }, + }, + { + id: "https://shaperepo.com/schemas/solidProfile#TypeRegistration", + 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: ["http://www.w3.org/ns/solid/terms#TypeRegistration"], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "Defines this node as a Type Registration", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://www.w3.org/ns/solid/terms#forClass", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The class of object at this type.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://www.w3.org/ns/solid/terms#instance", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: -1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "A specific resource that contains the class.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://www.w3.org/ns/solid/terms#instanceContainer", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: -1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "Containers that contain resources with the class.", + }, + }, + ], + }, + ], + }, + extra: ["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"], + }, + }, + ], +}; diff --git a/packages/solid-type-index/src/.ldo/typeIndex.shapeTypes.ts b/packages/solid-type-index/src/.ldo/typeIndex.shapeTypes.ts new file mode 100644 index 0000000..4896439 --- /dev/null +++ b/packages/solid-type-index/src/.ldo/typeIndex.shapeTypes.ts @@ -0,0 +1,28 @@ +import { ShapeType } from "@ldo/ldo"; +import { typeIndexSchema } from "./typeIndex.schema"; +import { typeIndexContext } from "./typeIndex.context"; +import { TypeIndexDocument, TypeRegistration } from "./typeIndex.typings"; + +/** + * ============================================================================= + * LDO ShapeTypes typeIndex + * ============================================================================= + */ + +/** + * TypeIndexDocument ShapeType + */ +export const TypeIndexDocumentShapeType: ShapeType = { + schema: typeIndexSchema, + shape: "https://shaperepo.com/schemas/solidProfile#TypeIndexDocument", + context: typeIndexContext, +}; + +/** + * TypeRegistration ShapeType + */ +export const TypeRegistrationShapeType: ShapeType = { + schema: typeIndexSchema, + shape: "https://shaperepo.com/schemas/solidProfile#TypeRegistration", + context: typeIndexContext, +}; diff --git a/packages/solid-type-index/src/.ldo/typeIndex.typings.ts b/packages/solid-type-index/src/.ldo/typeIndex.typings.ts new file mode 100644 index 0000000..53064ef --- /dev/null +++ b/packages/solid-type-index/src/.ldo/typeIndex.typings.ts @@ -0,0 +1,58 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * Typescript Typings for typeIndex + * ============================================================================= + */ + +/** + * TypeIndexDocument Type + */ +export interface TypeIndexDocument { + "@id"?: string; + "@context"?: ContextDefinition; + /** + * Defines the node as a TypeIndex | Defines the node as a Listed Document + */ + type: ( + | { + "@id": "TypeIndex"; + } + | { + "@id": "ListedDocument"; + } + )[]; +} + +/** + * TypeRegistration Type + */ +export interface TypeRegistration { + "@id"?: string; + "@context"?: ContextDefinition; + /** + * Defines this node as a Type Registration + */ + type: { + "@id": "TypeRegistration"; + }; + /** + * The class of object at this type. + */ + forClass: { + "@id": string; + }; + /** + * A specific resource that contains the class. + */ + instance?: { + "@id": string; + }[]; + /** + * Containers that contain resources with the class. + */ + instanceContainer?: { + "@id": string; + }[]; +} diff --git a/packages/solid-type-index/src/.shapes/profile.shex b/packages/solid-type-index/src/.shapes/profile.shex new file mode 100644 index 0000000..1274b6e --- /dev/null +++ b/packages/solid-type-index/src/.shapes/profile.shex @@ -0,0 +1,10 @@ +PREFIX srs: +PREFIX solid: +PREFIX rdfs: + +srs:TypeIndexProfile { + solid:privateTypeIndex IRI * + // rdfs:comment "A registry of all types used on the user's Pod (for private access only)" ; + solid:publicTypeIndex IRI * + // rdfs:comment "A registry of all types used on the user's Pod (for public access)" ; +} diff --git a/packages/solid-type-index/src/.shapes/typeIndex.shex b/packages/solid-type-index/src/.shapes/typeIndex.shex new file mode 100644 index 0000000..b0fd3db --- /dev/null +++ b/packages/solid-type-index/src/.shapes/typeIndex.shex @@ -0,0 +1,23 @@ +PREFIX srs: +PREFIX rdf: +PREFIX solid: +PREFIX vcard: +PREFIX rdfs: + +srs:TypeIndexDocument EXTRA a { + a [ solid:TypeIndex ] + // rdfs:comment "Defines the node as a TypeIndex" ; + a [ solid:ListedDocument ] + // rdfs:comment "Defines the node as a Listed Document" ; +} + +srs:TypeRegistration EXTRA a { + a [ solid:TypeRegistration ] + // rdfs:comment "Defines this node as a Type Registration" ; + solid:forClass IRI + // rdfs:comment "The class of object at this type." ; + solid:instance IRI * + // rdfs:comment "A specific resource that contains the class." ; + solid:instanceContainer IRI * + // rdfs:comment "Containers that contain resources with the class." ; +} diff --git a/packages/solid-type-index/src/constants.ts b/packages/solid-type-index/src/constants.ts new file mode 100644 index 0000000..830a590 --- /dev/null +++ b/packages/solid-type-index/src/constants.ts @@ -0,0 +1,7 @@ +export const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; +export const TYPE_REGISTRATION = + "http://www.w3.org/ns/solid/terms#TypeRegistration"; +export const FOR_CLASS = "http://www.w3.org/ns/solid/terms#forClass"; +export const INSTANCE = "http://www.w3.org/ns/solid/terms#instance"; +export const INSTANCE_CONTAINER = + "http://www.w3.org/ns/solid/terms#instanceContainer"; diff --git a/packages/solid-type-index/src/getTypeIndex.ts b/packages/solid-type-index/src/getTypeIndex.ts new file mode 100644 index 0000000..e34981e --- /dev/null +++ b/packages/solid-type-index/src/getTypeIndex.ts @@ -0,0 +1,92 @@ +import type { ContainerUri, LeafUri } from "@ldo/solid"; +import type { TypeRegistration } from "./.ldo/typeIndex.typings"; +import type { TypeIndexProfile } from "./.ldo/profile.typings"; +import { TypeIndexProfileShapeType } from "./.ldo/profile.shapeTypes"; +import { TypeRegistrationShapeType } from "./.ldo/typeIndex.shapeTypes"; +import { RDF_TYPE, TYPE_REGISTRATION } from "./constants"; +import type { Options } from "./util/Options"; +import { guaranteeOptions } from "./util/Options"; + +export async function getTypeRegistrations( + webId: string, + options?: Options, +): Promise { + const { dataset } = guaranteeOptions(options); + + // Get Profile + const profile = await getProfile(webId, options); + + // Get Type Indexes + const typeIndexUris = getTypeIndexesUrisFromProfile(profile); + + // Fetch the type Indexes + await Promise.all( + typeIndexUris.map(async (typeIndexUri) => { + const typeIndexResource = dataset.getResource(typeIndexUri); + const readResult = await typeIndexResource.readIfUnfetched(); + if (readResult.isError) throw readResult; + }), + ); + + // Get Type Registrations + return dataset + .usingType(TypeRegistrationShapeType) + .matchSubject(RDF_TYPE, TYPE_REGISTRATION); +} + +export async function getProfile( + webId: string, + options?: Options, +): Promise { + const { dataset } = guaranteeOptions(options); + const profileResource = dataset.getResource(webId); + const readResult = await profileResource.readIfUnfetched(); + if (readResult.isError) throw readResult; + return dataset.usingType(TypeIndexProfileShapeType).fromSubject(webId); +} + +export function getTypeIndexesUrisFromProfile( + profile: TypeIndexProfile, +): LeafUri[] { + const uris: LeafUri[] = []; + profile.privateTypeIndex?.forEach((indexNode) => { + uris.push(indexNode["@id"] as LeafUri); + }); + profile.publicTypeIndex?.forEach((indexNode) => { + uris.push(indexNode["@id"] as LeafUri); + }); + return uris; +} + +export async function getInstanceUris( + classUri: string, + typeRegistrations: TypeRegistration[], + options?: Options, +): Promise { + const { dataset } = guaranteeOptions(options); + + const leafUris = new Set(); + await Promise.all( + typeRegistrations.map(async (registration) => { + if (registration.forClass["@id"] === classUri) { + // Individual registrations + registration.instance?.forEach((instance) => + leafUris.add(instance["@id"] as LeafUri), + ); + // Container registrations + await Promise.all( + registration.instanceContainer?.map(async (instanceContainer) => { + const containerResource = dataset.getResource( + instanceContainer["@id"] as ContainerUri, + ); + await containerResource.readIfUnfetched(); + containerResource.children().forEach((child) => { + if (child.type === "leaf") leafUris.add(child.uri); + }); + }) ?? [], + ); + } + }), + ); + return Array.from(leafUris); +} diff --git a/packages/solid-type-index/src/index.ts b/packages/solid-type-index/src/index.ts new file mode 100644 index 0000000..9370593 --- /dev/null +++ b/packages/solid-type-index/src/index.ts @@ -0,0 +1,4 @@ +export * from "./getTypeIndex"; +export * from "./setTypeIndex"; +export * from "./react/useInstanceUris"; +export * from "./react/useTypeIndexProfile"; diff --git a/packages/solid-type-index/src/react/useInstanceUris.ts b/packages/solid-type-index/src/react/useInstanceUris.ts new file mode 100644 index 0000000..59717bc --- /dev/null +++ b/packages/solid-type-index/src/react/useInstanceUris.ts @@ -0,0 +1,45 @@ +import type { LeafUri } from "@ldo/solid"; +import { useTypeIndexProfile } from "./useTypeIndexProfile"; +import { useEffect, useMemo, useState } from "react"; +import { useSubscribeToUris } from "./util/useSubscribeToUris"; +import { useLdo, useMatchSubject } from "@ldo/solid-react"; +import { TypeRegistrationShapeType } from "../.ldo/typeIndex.shapeTypes"; +import { RDF_TYPE, TYPE_REGISTRATION } from "../constants"; +import { + getInstanceUris, + getTypeIndexesUrisFromProfile, +} from "../getTypeIndex"; + +/** + * Provides the LeafUris of everything in a type node for a specific class uri + * + * @param classUri - the class uri + * @returns - URIs of all resources registered with this node + */ +export function useInstanceUris(classUri: string): LeafUri[] { + const { dataset } = useLdo(); + const profile = useTypeIndexProfile(); + + const typeIndexUris: string[] = useMemo( + () => (profile ? getTypeIndexesUrisFromProfile(profile) : []), + [profile], + ); + + useSubscribeToUris(typeIndexUris); + + const [leafUris, setLeafUris] = useState([]); + + const typeRegistrations = useMatchSubject( + TypeRegistrationShapeType, + RDF_TYPE, + TYPE_REGISTRATION, + ); + + useEffect(() => { + getInstanceUris(classUri, typeRegistrations, { + solidLdoDataset: dataset, + }).then(setLeafUris); + }, [typeRegistrations]); + + return leafUris; +} diff --git a/packages/solid-type-index/src/react/useTypeIndexProfile.ts b/packages/solid-type-index/src/react/useTypeIndexProfile.ts new file mode 100644 index 0000000..aa7e0f1 --- /dev/null +++ b/packages/solid-type-index/src/react/useTypeIndexProfile.ts @@ -0,0 +1,10 @@ +import { useResource, useSolidAuth, useSubject } from "@ldo/solid-react"; +import type { TypeIndexProfile } from "../.ldo/profile.typings"; +import { TypeIndexProfileShapeType } from "../.ldo/profile.shapeTypes"; + +export function useTypeIndexProfile(): TypeIndexProfile | undefined { + const { session } = useSolidAuth(); + useResource(session.webId, { subscribe: true }); + const profile = useSubject(TypeIndexProfileShapeType, session.webId); + return profile; +} diff --git a/packages/solid-type-index/src/react/util/useSubscribeToUris.ts b/packages/solid-type-index/src/react/util/useSubscribeToUris.ts new file mode 100644 index 0000000..4141f34 --- /dev/null +++ b/packages/solid-type-index/src/react/util/useSubscribeToUris.ts @@ -0,0 +1,35 @@ +import { useLdo } from "@ldo/solid-react"; +import { useEffect, useRef } from "react"; + +export function useSubscribeToUris(uris: string[]) { + const { dataset } = useLdo(); + const currentlySubscribed = useRef>({}); + useEffect(() => { + const resources = uris.map((uri) => dataset.getResource(uri)); + const previousSubscriptions = { ...currentlySubscribed.current }; + Promise.all( + resources.map(async (resource) => { + if (!previousSubscriptions[resource.uri]) { + // Read and subscribe + await resource.readIfUnfetched(); + currentlySubscribed.current[resource.uri] = + await resource.subscribeToNotifications(); + } else { + delete previousSubscriptions[resource.uri]; + } + }), + ).then(async () => { + // Unsubscribe from all remaining previous subscriptions + await Promise.all( + Object.entries(previousSubscriptions).map( + async ([resourceUri, subscriptionId]) => { + // Unsubscribe + delete currentlySubscribed.current[resourceUri]; + const resource = dataset.getResource(resourceUri); + await resource.unsubscribeFromNotifications(subscriptionId); + }, + ), + ); + }); + }, [uris]); +} diff --git a/packages/solid-type-index/src/setTypeIndex.ts b/packages/solid-type-index/src/setTypeIndex.ts new file mode 100644 index 0000000..1c784dc --- /dev/null +++ b/packages/solid-type-index/src/setTypeIndex.ts @@ -0,0 +1,207 @@ +import { v4 } from "uuid"; +import { + TypeIndexDocumentShapeType, + TypeRegistrationShapeType, +} from "./.ldo/typeIndex.shapeTypes"; +import { FOR_CLASS, RDF_TYPE, TYPE_REGISTRATION } from "./constants"; +import { guaranteeOptions, type Options } from "./util/Options"; +import { namedNode, quad } from "@rdfjs/data-model"; +import type { TypeRegistration } from "./.ldo/typeIndex.typings"; +import { getProfile } from "./getTypeIndex"; +import { TypeIndexProfileShapeType } from "./.ldo/profile.shapeTypes"; +import type { Container } from "@ldo/solid"; +import type { ISolidLdoDataset } from "@ldo/solid"; +import type { NamedNode } from "@rdfjs/types"; + +/** + * ============================================================================= + * INITIALIZERS + * ============================================================================= + */ +export async function initTypeIndex( + webId: string, + options?: Options, +): Promise { + const { dataset } = guaranteeOptions(options); + const profile = await getProfile(webId, options); + if (!profile.privateTypeIndex?.length || !profile.publicTypeIndex?.length) { + const profileFolder = await dataset.getResource(webId).getParentContainer(); + if (profileFolder?.isError) throw profileFolder; + if (!profileFolder) + throw new Error("No folder to save the type indexes to."); + if (!profile.privateTypeIndex?.length) { + await createIndex(webId, profileFolder, dataset, true); + } + if (!profile.publicTypeIndex?.length) { + await createIndex(webId, profileFolder, dataset, false); + } + } +} + +/** + * @internal + * @param webId + * @param profileFolder + * @param dataset + */ +export async function createIndex( + webId, + profileFolder: Container, + dataset: ISolidLdoDataset, + isPrivate: boolean, +) { + // Create a private type index + const createResult = await profileFolder.createChildAndOverwrite( + `${isPrivate ? "private" : "public"}_index_${v4()}`, + ); + if (createResult.isError) throw createResult; + const indexResource = createResult.resource; + const wacResult = await indexResource.setWac({ + agent: { + [webId]: { read: true, write: true, append: true, control: true }, + }, + public: { + read: isPrivate ? false : true, + write: true, + append: true, + control: true, + }, + authenticated: { + read: isPrivate ? false : true, + write: true, + append: true, + control: true, + }, + }); + if (wacResult.isError) throw wacResult; + const transaction = dataset.startTransaction(); + const cProfile = transaction + .usingType(TypeIndexProfileShapeType) + .write(dataset.getResource(webId).uri) + .fromSubject(webId); + if (isPrivate) { + cProfile.privateTypeIndex?.push({ "@id": indexResource.uri }); + } else { + cProfile.publicTypeIndex?.push({ "@id": indexResource.uri }); + } + const cTypeIndex = transaction + .usingType(TypeIndexDocumentShapeType) + .write(indexResource.uri) + .fromSubject(indexResource.uri); + + cTypeIndex.type = [{ "@id": "ListedDocument" }, { "@id": "TypeIndex" }]; + const commitResult = await transaction.commitToPod(); + if (commitResult.isError) throw commitResult; +} + +/** + * ============================================================================= + * DATASET MODIFIERS + * ============================================================================= + */ +interface Instances { + instance?: string[]; + instanceContainer?: string[]; +} + +export function addRegistration( + indexUri: string, + classUri: string, + instances: Instances, + options?: Options, +): void { + // Check to see if its already in the index + const typeRegistration = findAppropriateTypeRegistration( + indexUri, + classUri, + options, + ); + + // Add instances to type registration + instances.instance?.forEach((instance) => { + typeRegistration.instance?.push({ "@id": instance }); + }); + instances.instanceContainer?.forEach((instanceContainer) => { + typeRegistration.instanceContainer?.push({ "@id": instanceContainer }); + }); +} + +export async function removeRegistration( + indexUri: string, + classUri: string, + instances: Instances, + options?: Options, +) { + // Check to see if its already in the index + const typeRegistration = findAppropriateTypeRegistration( + indexUri, + classUri, + options, + ); + + console.log(typeRegistration["@id"]); + + // Add instances to type registration + instances.instance?.forEach((instance) => { + typeRegistration.instance?.splice( + typeRegistration.instance.findIndex((val) => val["@id"] === instance), + 1, + ); + }); + instances.instanceContainer?.forEach((instanceContainer) => { + console.log("Splicing instanceContainers", instanceContainer); + typeRegistration.instanceContainer?.splice( + typeRegistration.instanceContainer.findIndex( + (val) => val["@id"] === instanceContainer, + ), + 1, + ); + }); +} + +export function findAppropriateTypeRegistration( + indexUri: string, + classUri: string, + options?: Options, +) { + const { dataset } = guaranteeOptions(options); + // Check to see if its already in the index + const existingRegistrationsUris: NamedNode[] = dataset + .match( + null, + namedNode(RDF_TYPE), + namedNode(TYPE_REGISTRATION), + namedNode(indexUri), + ) + .toArray() + .map((quad) => quad.subject) as NamedNode[]; + + const existingRegistrationForClassUri = existingRegistrationsUris.find( + (registrationUri) => { + return dataset.has( + quad( + registrationUri, + namedNode(FOR_CLASS), + namedNode(classUri), + namedNode(indexUri), + ), + ); + }, + )?.value; + + let typeRegistration: TypeRegistration; + if (existingRegistrationForClassUri) { + typeRegistration = dataset + .usingType(TypeRegistrationShapeType) + .write(indexUri) + .fromSubject(existingRegistrationForClassUri); + } else { + typeRegistration = dataset + .usingType(TypeRegistrationShapeType) + .write(indexUri) + .fromSubject(`${indexUri}#${v4()}`); + typeRegistration.type = { "@id": "TypeRegistration" }; + typeRegistration.forClass = { "@id": classUri }; + } + return typeRegistration; +} diff --git a/packages/solid-type-index/src/util/Options.ts b/packages/solid-type-index/src/util/Options.ts new file mode 100644 index 0000000..d551bf3 --- /dev/null +++ b/packages/solid-type-index/src/util/Options.ts @@ -0,0 +1,14 @@ +import { createSolidLdoDataset } from "@ldo/solid"; +import type { ISolidLdoDataset } from "@ldo/solid"; +import { guaranteeFetch } from "@ldo/solid/dist/util/guaranteeFetch"; + +export interface Options { + solidLdoDataset?: ISolidLdoDataset; + fetch?: typeof fetch; +} + +export function guaranteeOptions(options?: Options) { + const fetch = guaranteeFetch(options?.fetch); + const dataset = options?.solidLdoDataset ?? createSolidLdoDataset({ fetch }); + return { fetch, dataset }; +} diff --git a/packages/solid-type-index/test/General.test.tsx b/packages/solid-type-index/test/General.test.tsx new file mode 100644 index 0000000..7cf8380 --- /dev/null +++ b/packages/solid-type-index/test/General.test.tsx @@ -0,0 +1,138 @@ +import { createSolidLdoDataset } from "@ldo/solid"; +import { + MY_BOOKMARKS_1_URI, + MY_BOOKMARKS_2_URI, + PRIVATE_TYPE_INDEX_URI, + PUBLIC_TYPE_INDEX_URI, + ROOT_CONTAINER, + setupEmptyTypeIndex, + setupFullTypeIndex, + setUpServer, + WEB_ID, +} from "./setUpServer"; +import { getInstanceUris, getTypeRegistrations } from "../src/getTypeIndex"; +import { + addRegistration, + initTypeIndex, + removeRegistration, +} from "../src/setTypeIndex"; +import { TypeIndexProfileShapeType } from "../src/.ldo/profile.shapeTypes"; +import { namedNode } from "@rdfjs/dataset"; +import { INSTANCE } from "../src/constants"; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +const ADDRESS_BOOK = "http://www.w3.org/2006/vcard/ns#AddressBook"; +const BOOKMARK = "http://www.w3.org/2002/01/bookmark#Bookmark"; +const EXAMPLE_THING = "https://example.com/ExampleThing"; + +describe("General Tests", () => { + const s = setUpServer(); + + it("gets the current typeindex", async () => { + await setupFullTypeIndex(s); + + const solidLdoDataset = createSolidLdoDataset(); + const typeRegistrations = await getTypeRegistrations(WEB_ID, { + solidLdoDataset, + }); + const addressBookUris = await getInstanceUris( + ADDRESS_BOOK, + typeRegistrations, + { solidLdoDataset }, + ); + expect(addressBookUris).toEqual( + expect.arrayContaining([ + "https://example.com/myPrivateAddressBook.ttl", + "https://example.com/myPublicAddressBook.ttl", + ]), + ); + const bookmarkUris = await getInstanceUris(BOOKMARK, typeRegistrations, { + solidLdoDataset, + }); + expect(bookmarkUris).toEqual( + expect.arrayContaining([MY_BOOKMARKS_1_URI, MY_BOOKMARKS_2_URI]), + ); + }); + + it("initializes the type index", async () => { + await setupEmptyTypeIndex(s); + + const solidLdoDataset = createSolidLdoDataset(); + + await initTypeIndex(WEB_ID, { solidLdoDataset }); + + const profile = solidLdoDataset + .usingType(TypeIndexProfileShapeType) + .fromSubject(WEB_ID); + + expect(profile.privateTypeIndex?.[0]?.["@id"]).toBeDefined(); + expect(profile.publicTypeIndex?.[0]?.["@id"]).toBeDefined(); + }); + + it("Adds to the typeIndex", async () => { + await setupFullTypeIndex(s); + + const solidLdoDataset = createSolidLdoDataset(); + + await getTypeRegistrations(WEB_ID, { solidLdoDataset }); + + const transaction = solidLdoDataset.startTransaction(); + addRegistration( + PUBLIC_TYPE_INDEX_URI, + ADDRESS_BOOK, + { instance: ["https://example.com/AdressBook3"] }, + { solidLdoDataset: transaction }, + ); + addRegistration( + PRIVATE_TYPE_INDEX_URI, + EXAMPLE_THING, + { instanceContainer: ["https://example.com/ExampleInstance"] }, + { solidLdoDataset: transaction }, + ); + const { added, removed } = transaction.getChanges(); + + const existingRegistration = namedNode( + "http://localhost:3003/example/profile/publicTypeIndex.ttl#ab09fd", + ); + + expect(removed).not.toBeDefined(); + expect(added?.size).toBe(4); + expect(added?.match(existingRegistration).size).toBe(1); + expect( + added?.match( + existingRegistration, + namedNode(INSTANCE), + namedNode("https://example.com/AdressBook3"), + namedNode("http://localhost:3003/example/profile/publicTypeIndex.ttl"), + ).size, + ).toBe(1); + }); + + it("Removes from the typeIndex", async () => { + await setupFullTypeIndex(s); + + const solidLdoDataset = createSolidLdoDataset(); + + await getTypeRegistrations(WEB_ID, { solidLdoDataset }); + + const transaction = solidLdoDataset.startTransaction(); + removeRegistration( + PUBLIC_TYPE_INDEX_URI, + ADDRESS_BOOK, + { instance: ["https://example.com/myPublicAddressBook.ttl"] }, + { solidLdoDataset: transaction }, + ); + removeRegistration( + PRIVATE_TYPE_INDEX_URI, + BOOKMARK, + { instanceContainer: [`${ROOT_CONTAINER}myBookmarks/`] }, + { solidLdoDataset: transaction }, + ); + const { added, removed } = transaction.getChanges(); + + expect(added).not.toBeDefined(); + expect(removed?.size).toBe(2); + }); +}); diff --git a/packages/solid-type-index/test/setUpServer.ts b/packages/solid-type-index/test/setUpServer.ts new file mode 100644 index 0000000..04fc479 --- /dev/null +++ b/packages/solid-type-index/test/setUpServer.ts @@ -0,0 +1,153 @@ +import fetch from "cross-fetch"; + +export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3003/"; +export const ROOT_ROUTE = process.env.ROOT_CONTAINER || "example/"; +export const ROOT_CONTAINER = `${SERVER_DOMAIN}${ROOT_ROUTE}`; +export const PROFILE_CONTAINER = `${ROOT_CONTAINER}profile/`; +export const WEB_ID = `${PROFILE_CONTAINER}card.ttl#me`; +export const PUBLIC_TYPE_INDEX_URI = `${PROFILE_CONTAINER}publicTypeIndex.ttl`; +export const PRIVATE_TYPE_INDEX_URI = `${PROFILE_CONTAINER}privateTypeIndex.ttl`; +export const MY_BOOKMARKS_CONTAINER = `${ROOT_CONTAINER}myBookmarks/`; +export const MY_BOOKMARKS_1_URI = `${ROOT_CONTAINER}myBookmarks/bookmark1.ttl`; +export const MY_BOOKMARKS_2_URI = `${ROOT_CONTAINER}myBookmarks/bookmark2.ttl`; + +export const PROFILE_TTL = ` +<#me> <${PROFILE_CONTAINER}publicTypeIndex.ttl> ; + <${PROFILE_CONTAINER}privateTypeIndex.ttl> .`; +export const PUBLIC_TYPE_INDEX_TTL = `@prefix solid: . +@prefix vcard: . +@prefix bk: . + +<> + a solid:TypeIndex ; + a solid:ListedDocument. + +<#ab09fd> a solid:TypeRegistration; + solid:forClass vcard:AddressBook; + solid:instance . + +<#bq1r5e> a solid:TypeRegistration; + solid:forClass bk:Bookmark; + solid:instanceContainer <${ROOT_CONTAINER}myBookmarks/>.`; +export const PRIVATE_TYPE_INDEX_TTL = `@prefix solid: . +@prefix vcard: . +@prefix bk: . + +<> + a solid:TypeIndex ; + a solid:UnlistedDocument. + +<#ab09fd> a solid:TypeRegistration; + solid:forClass vcard:AddressBook; + solid:instance . + +<#bq1r5e> a solid:TypeRegistration; + solid:forClass bk:Bookmark; + solid:instanceContainer <${ROOT_CONTAINER}myBookmarks/>.`; + +export interface SetUpServerReturn { + authFetch: typeof fetch; + fetchMock: jest.Mock< + Promise, + [input: RequestInfo | URL, init?: RequestInit | undefined] + >; +} + +export async function setupFullTypeIndex(s: SetUpServerReturn) { + // Create a new document called sample.ttl + await s.authFetch(WEB_ID, { method: "DELETE" }); + await s.authFetch(ROOT_CONTAINER, { + method: "POST", + headers: { + link: '; rel="type"', + slug: "myBookmarks/", + }, + }); + await Promise.all([ + s.authFetch(PROFILE_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "card.ttl" }, + body: PROFILE_TTL, + }), + s.authFetch(PROFILE_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "publicTypeIndex.ttl" }, + body: PUBLIC_TYPE_INDEX_TTL, + }), + s.authFetch(PROFILE_CONTAINER, { + method: "POST", + headers: { + "content-type": "text/turtle", + slug: "privateTypeIndex.ttl", + }, + body: PRIVATE_TYPE_INDEX_TTL, + }), + s.authFetch(MY_BOOKMARKS_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "bookmark1.ttl" }, + body: "", + }), + s.authFetch(MY_BOOKMARKS_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "bookmark2.ttl" }, + body: "", + }), + ]); +} + +export async function setupEmptyTypeIndex(s: SetUpServerReturn) { + // Create a new document called sample.ttl + await s.authFetch(WEB_ID, { method: "DELETE" }); + await s.authFetch(ROOT_CONTAINER, { + method: "POST", + headers: { + link: '; rel="type"', + slug: "myBookmarks/", + }, + }); + await Promise.all([ + s.authFetch(PROFILE_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "card.ttl" }, + body: "", + }), + s.authFetch(MY_BOOKMARKS_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "bookmark1.ttl" }, + body: "", + }), + s.authFetch(MY_BOOKMARKS_CONTAINER, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "bookmark2.ttl" }, + body: "", + }), + ]); +} + +export function setUpServer(): SetUpServerReturn { + // Ignore to build s + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const s: SetUpServerReturn = {}; + + beforeAll(async () => { + // s.authFetch = await getAuthenticatedFetch(); + s.authFetch = fetch; + }); + + beforeEach(async () => { + s.fetchMock = jest.fn(s.authFetch); + }); + + afterEach(async () => { + await Promise.all([ + await s.authFetch(WEB_ID, { method: "DELETE" }), + await s.authFetch(PUBLIC_TYPE_INDEX_URI, { method: "DELETE" }), + await s.authFetch(PRIVATE_TYPE_INDEX_URI, { method: "DELETE" }), + await s.authFetch(MY_BOOKMARKS_1_URI, { method: "DELETE" }), + await s.authFetch(MY_BOOKMARKS_2_URI, { method: "DELETE" }), + ]); + }); + + return s; +} diff --git a/packages/solid-type-index/test/test-server/configs/components-config/unauthenticatedServer.json b/packages/solid-type-index/test/test-server/configs/components-config/unauthenticatedServer.json new file mode 100644 index 0000000..ff01914 --- /dev/null +++ b/packages/solid-type-index/test/test-server/configs/components-config/unauthenticatedServer.json @@ -0,0 +1,52 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-intro.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/all.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A Solid server that stores its resources in memory and uses WAC for authorization." + }, + { + "comment": "The location of the new pod templates folder.", + "@type": "Override", + "overrideInstance": { + "@id": "urn:solid-server:default:PodResourcesGenerator" + }, + "overrideParameters": { + "@type": "StaticFolderGenerator", + "templateFolder": "./test/test-server/configs/template" + } + } + ] +} diff --git a/packages/solid-type-index/test/test-server/configs/solid-css-seed.json b/packages/solid-type-index/test/test-server/configs/solid-css-seed.json new file mode 100644 index 0000000..5894d0d --- /dev/null +++ b/packages/solid-type-index/test/test-server/configs/solid-css-seed.json @@ -0,0 +1,9 @@ +[ + { + "email": "hello@example.com", + "password": "abc123", + "pods": [ + { "name": "example" } + ] + } +] \ No newline at end of file diff --git a/packages/solid-type-index/test/test-server/configs/template/wac/.acl.hbs b/packages/solid-type-index/test/test-server/configs/template/wac/.acl.hbs new file mode 100644 index 0000000..48fd101 --- /dev/null +++ b/packages/solid-type-index/test/test-server/configs/template/wac/.acl.hbs @@ -0,0 +1,13 @@ +@prefix : <#>. +@prefix acl: . +@prefix foaf: . +@prefix eve: <./>. +@prefix c: <./profile/card#>. + +:ControlReadWrite + a acl:Authorization; + acl:accessTo eve:; + acl:agent c:me, ; + acl:agentClass foaf:Agent; + acl:default eve:; + acl:mode acl:Control, acl:Read, acl:Write. \ No newline at end of file diff --git a/packages/solid-type-index/test/test-server/configs/template/wac/profile/card.acl.hbs b/packages/solid-type-index/test/test-server/configs/template/wac/profile/card.acl.hbs new file mode 100644 index 0000000..ea7c2a8 --- /dev/null +++ b/packages/solid-type-index/test/test-server/configs/template/wac/profile/card.acl.hbs @@ -0,0 +1,19 @@ +# ACL resource for the WebID profile document +@prefix acl: . +@prefix foaf: . + +# The WebID profile is readable by the public. +# This is required for discovery and verification, +# e.g. when checking identity providers. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./card>; + acl:mode acl:Read. + +# The owner has full access to the profile +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./card>; + acl:mode acl:Read, acl:Write, acl:Control. \ No newline at end of file diff --git a/packages/solid-type-index/test/test-server/runServer.ts b/packages/solid-type-index/test/test-server/runServer.ts new file mode 100644 index 0000000..9d91222 --- /dev/null +++ b/packages/solid-type-index/test/test-server/runServer.ts @@ -0,0 +1,7 @@ +import { createApp } from "./solidServer.helper"; + +async function run() { + const app = await createApp(); + await app.start(); +} +run(); diff --git a/packages/solid-type-index/test/test-server/solidServer.helper.ts b/packages/solid-type-index/test/test-server/solidServer.helper.ts new file mode 100644 index 0000000..9dd4703 --- /dev/null +++ b/packages/solid-type-index/test/test-server/solidServer.helper.ts @@ -0,0 +1,39 @@ +// Taken from https://github.com/comunica/comunica/blob/b237be4265c353a62a876187d9e21e3bc05123a3/engines/query-sparql/test/QuerySparql-solid-test.ts#L9 + +import * as path from "path"; +import type { App } from "@solid/community-server"; +import { AppRunner, resolveModulePath } from "@solid/community-server"; + +export async function createApp(): Promise { + if (process.env.SERVER) { + return { + start: () => {}, + stop: () => {}, + } as App; + } + const appRunner = new AppRunner(); + + return appRunner.create({ + loaderProperties: { + mainModulePath: resolveModulePath(""), + typeChecking: false, + }, + config: path.join( + __dirname, + "configs", + "components-config", + "unauthenticatedServer.json", + ), + variableBindings: {}, + shorthand: { + port: 3_003, + loggingLevel: "off", + seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"), + }, + }); +} + +export interface ISecretData { + id: string; + secret: string; +} diff --git a/packages/solid-type-index/tsconfig.build.json b/packages/solid-type-index/tsconfig.build.json new file mode 100644 index 0000000..e375629 --- /dev/null +++ b/packages/solid-type-index/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["dom"] + }, + "include": ["./src"] +} \ No newline at end of file diff --git a/packages/solid/src/SolidLdoDataset.ts b/packages/solid/src/SolidLdoDataset.ts index 034235d..d95bf44 100644 --- a/packages/solid/src/SolidLdoDataset.ts +++ b/packages/solid/src/SolidLdoDataset.ts @@ -15,6 +15,7 @@ import type { NoRootContainerError } from "./requester/results/error/NoRootConta import type { ReadResultError } from "./requester/requests/readResource"; import { ProfileWithStorageShapeType } from "./.ldo/solid.shapeTypes"; import type { GetStorageContainerFromWebIdSuccess } from "./requester/results/success/CheckRootContainerSuccess"; +import type { ISolidLdoDataset } from "./types"; /** * A SolidLdoDataset has all the functionality of an LdoDataset with the added @@ -41,7 +42,7 @@ import type { GetStorageContainerFromWebIdSuccess } from "./requester/results/su * .fromSubject("https://example.com/profile#me"); * ``` */ -export class SolidLdoDataset extends LdoDataset { +export class SolidLdoDataset extends LdoDataset implements ISolidLdoDataset { /** * @internal */ diff --git a/packages/solid/src/SolidLdoTransactionDataset.ts b/packages/solid/src/SolidLdoTransactionDataset.ts index cbacbb3..4a2306e 100644 --- a/packages/solid/src/SolidLdoTransactionDataset.ts +++ b/packages/solid/src/SolidLdoTransactionDataset.ts @@ -14,10 +14,10 @@ import { updateDatasetInBulk, type ITransactionDatasetFactory, } from "@ldo/subscribable-dataset"; -import type { SolidLdoDataset } from "./SolidLdoDataset"; import type { AggregateSuccess } from "./requester/results/success/SuccessResult"; import type { ResourceResult } from "./resource/resourceResult/ResourceResult"; import type { + IgnoredInvalidUpdateSuccess, UpdateDefaultGraphSuccess, UpdateSuccess, } from "./requester/results/success/UpdateSuccess"; @@ -26,7 +26,6 @@ import type { UpdateResult, UpdateResultError, } from "./requester/requests/updateDataResource"; -import { InvalidUriError } from "./requester/results/error/InvalidUriError"; import type { DatasetChanges, GraphNode } from "@ldo/rdf-utils"; import { splitChangesByGraph } from "./util/splitChangesByGraph"; @@ -75,7 +74,7 @@ export class SolidLdoTransactionDataset * @param initialDataset - A set of triples to initialize this dataset */ constructor( - parentDataset: SolidLdoDataset, + parentDataset: ISolidLdoDataset, context: SolidLdoDatasetContext, datasetFactory: DatasetFactory, transactionDatasetFactory: ITransactionDatasetFactory, @@ -108,11 +107,20 @@ export class SolidLdoTransactionDataset return this.context.resourceStore.get(uri, options); } + public startTransaction(): SolidLdoTransactionDataset { + return new SolidLdoTransactionDataset( + this, + this.context, + this.datasetFactory, + this.transactionDatasetFactory, + ); + } + async commitToPod(): Promise< | AggregateSuccess< ResourceResult > - | AggregateError + | AggregateError > { const changes = this.getChanges(); const changesByGraph = splitChangesByGraph(changes); @@ -121,7 +129,7 @@ export class SolidLdoTransactionDataset const results: [ GraphNode, DatasetChanges, - UpdateResult | InvalidUriError | UpdateDefaultGraphSuccess, + UpdateResult | IgnoredInvalidUpdateSuccess | UpdateDefaultGraphSuccess, ][] = await Promise.all( Array.from(changesByGraph.entries()).map( async ([graph, datasetChanges]) => { @@ -141,10 +149,10 @@ export class SolidLdoTransactionDataset return [ graph, datasetChanges, - new InvalidUriError( - graph.value, - `Container URIs are not allowed for custom data.`, - ), + { + type: "ignoredInvalidUpdateSuccess", + isError: false, + } as IgnoredInvalidUpdateSuccess, ]; } const resource = this.getResource(graph.value as LeafUri); @@ -159,9 +167,7 @@ export class SolidLdoTransactionDataset if (errors.length > 0) { return new AggregateError( - errors.map( - (result) => result[2] as UpdateResultError | InvalidUriError, - ), + errors.map((result) => result[2] as UpdateResultError), ); } return { @@ -172,7 +178,8 @@ export class SolidLdoTransactionDataset .filter( (result): result is ResourceResult => result.type === "updateSuccess" || - result.type === "updateDefaultGraphSuccess", + result.type === "updateDefaultGraphSuccess" || + result.type === "ignoredInvalidUpdateSuccess", ), }; } diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index bb84a05..7692142 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -27,3 +27,5 @@ export * from "./resource/wac/results/GetWacRuleSuccess"; export * from "./resource/wac/results/GetWacUriSuccess"; export * from "./resource/wac/results/SetWacRuleSuccess"; export * from "./resource/wac/results/WacRuleAbsent"; + +export * from "./types"; \ No newline at end of file diff --git a/packages/solid/src/requester/results/success/UpdateSuccess.ts b/packages/solid/src/requester/results/success/UpdateSuccess.ts index a22a06c..5b740a0 100644 --- a/packages/solid/src/requester/results/success/UpdateSuccess.ts +++ b/packages/solid/src/requester/results/success/UpdateSuccess.ts @@ -14,3 +14,11 @@ export interface UpdateSuccess extends ResourceSuccess { export interface UpdateDefaultGraphSuccess extends ResourceSuccess { type: "updateDefaultGraphSuccess"; } + +/** + * Indicates that LDO ignored an invalid update (usually because a container + * attempted an update) + */ +export interface IgnoredInvalidUpdateSuccess extends ResourceSuccess { + type: "ignoredInvalidUpdateSuccess"; +} diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 5810428..79c6e1c 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -37,9 +37,8 @@ import { setWacRuleForAclUri, type SetWacRuleResult } from "./wac/setWacRule"; import type { LeafUri } from "../util/uriTypes"; import type { NoRootContainerError } from "../requester/results/error/NoRootContainerError"; import type { - CloseSubscriptionResult, NotificationSubscription, - OpenSubscriptionResult, + SubscriptionCallbacks, } from "./notifications/NotificationSubscription"; import { Websocket2023NotificationSubscription } from "./notifications/Websocket2023NotificationSubscription"; import type { NotificationMessage } from "./notifications/NotificationMessage"; @@ -111,7 +110,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * @internal * Handles notification subscriptions */ - protected notificationSubscription?: NotificationSubscription; + protected notificationSubscription: NotificationSubscription; /** * @param context - SolidLdoDatasetContext for the parent dataset @@ -119,6 +118,11 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ constructor(context: SolidLdoDatasetContext) { super(); this.context = context; + this.notificationSubscription = new Websocket2023NotificationSubscription( + this, + this.onNotification.bind(this), + this.context, + ); } /** @@ -324,17 +328,13 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * * @example * ```typescript - * // Logs "undefined" - * console.log(resource.isPresent()); - * const result = resource.read(); - * if (!result.isError) { - * // True if the resource exists, false if it does not - * console.log(resource.isPresent()); - * } + * await resource.subscribeToNotifications(); + * // Logs "true" + * console.log(resource.isSubscribedToNotifications()); * ``` */ isSubscribedToNotifications(): boolean { - return !!this.notificationSubscription; + return this.notificationSubscription.isSubscribedToNotifications(); } /** @@ -735,7 +735,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * * @param onNotificationError - A callback function if there is an error * with notifications. - * @returns OpenSubscriptionResult + * @returns SubscriptionId: A string to use to unsubscribe * * @example * ```typescript @@ -754,19 +754,20 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ); * * // Subscribe - * const subscriptionResult = await testContainer.subscribeToNotifications(); - * // ... From there you can ait for a file to be changed on the Pod. + * const subscriptionId = await testContainer.subscribeToNotifications({ + * // These are optional callbacks. A subscription will automatically keep + * // the dataset in sync. Use these callbacks for additional functionality. + * onNotification: (message) => console.log(message), + * onNotificationError: (err) => console.log(err.message) + * }); + * // ... From there you can wait for a file to be changed on the Pod. */ async subscribeToNotifications( - onNotificationError?: (err: Error) => void, - ): Promise { - this.notificationSubscription = new Websocket2023NotificationSubscription( - this, - this.onNotification.bind(this), - onNotificationError, - this.context, + callbacks?: SubscriptionCallbacks, + ): Promise { + return await this.notificationSubscription.subscribeToNotifications( + callbacks, ); - return await this.notificationSubscription.open(); } /** @@ -802,22 +803,32 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ /** * Unsubscribes from changes made to this resource on the Pod * - * @returns CloseSubscriptionResult + * @returns UnsubscribeResult * * @example * ```typescript - * resource.unsubscribeFromNotifications() + * const subscriptionId = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromNotifications(subscriptionId); * ``` */ - async unsubscribeFromNotifications(): Promise { - const result = await this.notificationSubscription?.close(); - this.notificationSubscription = undefined; - return ( - result ?? { - type: "unsubscribeFromNotificationSuccess", - isError: false, - uri: this.uri, - } + async unsubscribeFromNotifications(subscriptionId: string): Promise { + return this.notificationSubscription.unsubscribeFromNotification( + subscriptionId, ); } + + /** + * Unsubscribes from all notifications on this resource + * + * @returns UnsubscribeResult[] + * + * @example + * ```typescript + * const subscriptionResult = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromAllNotifications(); + * ``` + */ + async unsubscribeFromAllNotifications(): Promise { + return this.notificationSubscription.unsubscribeFromAllNotifications(); + } } diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index 87ede6d..4961075 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -1,19 +1,14 @@ -import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; import type { Resource } from "../Resource"; import type { NotificationMessage } from "./NotificationMessage"; -import type { UnsupportedNotificationError } from "./results/NotificationErrors"; -import type { SubscribeToNotificationSuccess } from "./results/SubscribeToNotificationSuccess"; -import type { UnsubscribeToNotificationSuccess } from "./results/UnsubscribeFromNotificationSuccess"; +import type { NotificationCallbackError } from "./results/NotificationErrors"; +import { v4 } from "uuid"; -export type OpenSubscriptionResult = - | SubscribeToNotificationSuccess - | UnsupportedNotificationError - | UnexpectedResourceError; - -export type CloseSubscriptionResult = - | UnsubscribeToNotificationSuccess - | UnexpectedResourceError; +export interface SubscriptionCallbacks { + onNotification?: (message: NotificationMessage) => void; + // TODO: make notification errors more specific + onNotificationError?: (error: Error) => void; +} /** * @internal @@ -21,31 +16,129 @@ export type CloseSubscriptionResult = */ export abstract class NotificationSubscription { protected resource: Resource; - protected onNotification: (message: NotificationMessage) => void; - protected onError?: (err: Error) => void; + protected parentSubscription: (message: NotificationMessage) => void; protected context: SolidLdoDatasetContext; + protected subscriptions: Record = {}; + private isOpen: boolean = false; constructor( resource: Resource, - onNotification: (message: NotificationMessage) => void, - onError: ((err: Error) => void) | undefined, + parentSubscription: (message: NotificationMessage) => void, context: SolidLdoDatasetContext, ) { this.resource = resource; - this.onNotification = onNotification; - this.onError = onError; + this.parentSubscription = parentSubscription; this.context = context; } + public isSubscribedToNotifications(): boolean { + return this.isOpen; + } + + /** + * =========================================================================== + * PUBLIC + * =========================================================================== + */ + + /** + * @internal + * subscribeToNotifications + */ + async subscribeToNotifications( + subscriptionCallbacks?: SubscriptionCallbacks, + ): Promise { + const subscriptionId = v4(); + this.subscriptions[subscriptionId] = subscriptionCallbacks ?? {}; + if (!this.isOpen) { + await this.open(); + this.setIsOpen(true); + } + return subscriptionId; + } + + /** + * @internal + * unsubscribeFromNotification + */ + async unsubscribeFromNotification(subscriptionId: string): Promise { + if ( + !!this.subscriptions[subscriptionId] && + Object.keys(this.subscriptions).length === 1 + ) { + await this.close(); + this.setIsOpen(false); + } + delete this.subscriptions[subscriptionId]; + } + + /** + * @internal + * unsubscribeFromAllNotifications + */ + async unsubscribeFromAllNotifications(): Promise { + await Promise.all( + Object.keys(this.subscriptions).map((id) => + this.unsubscribeFromNotification(id), + ), + ); + } + + /** + * =========================================================================== + * HELPERS + * =========================================================================== + */ + /** * @internal * Opens the subscription */ - abstract open(): Promise; + protected abstract open(): Promise; /** * @internal * Closes the subscription */ - abstract close(): Promise; + protected abstract close(): Promise; + + /** + * =========================================================================== + * CALLBACKS + * =========================================================================== + */ + + /** + * @internal + * onNotification + */ + protected onNotification(message: NotificationMessage): void { + this.parentSubscription(message); + Object.values(this.subscriptions).forEach(({ onNotification }) => { + onNotification?.(message); + }); + } + + /** + * @internal + * onNotificationError + */ + protected onNotificationError(message: NotificationCallbackError): void { + Object.values(this.subscriptions).forEach(({ onNotificationError }) => { + onNotificationError?.(message); + }); + if (message.type === "disconnectedNotAttemptingReconnectError") { + this.setIsOpen(false); + } + } + + /** + * @internal + * setIsOpen + */ + protected setIsOpen(status: boolean) { + const shouldUpdate = status !== this.isOpen; + this.isOpen = status; + if (shouldUpdate) this.resource.emit("update"); + } } diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts index 5434b7b..80c51cd 100644 --- a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -1,12 +1,12 @@ import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; -import type { - CloseSubscriptionResult, - OpenSubscriptionResult, -} from "./NotificationSubscription"; import { NotificationSubscription } from "./NotificationSubscription"; import { SubscriptionClient } from "@solid-notifications/subscription"; import { WebSocket } from "ws"; -import { UnsupportedNotificationError } from "./results/NotificationErrors"; +import { + DisconnectedAttemptingReconnectError, + DisconnectedNotAttemptingReconnectError, + UnsupportedNotificationError, +} from "./results/NotificationErrors"; import type { NotificationMessage } from "./NotificationMessage"; import type { Resource } from "../Resource"; import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; @@ -22,33 +22,48 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript private socket: WebSocket | undefined; private createWebsocket: (address: string) => WebSocket; + // Reconnection data + // How often we should attempt a reconnection + private reconnectInterval = 5000; + // How many attempts have already been tried for a reconnection + private reconnectAttempts = 0; + // Whether or not the socket was manually closes + private isManualClose = false; + // Maximum number of attempts to reconnect + private maxReconnectAttempts = 6; + constructor( resource: Resource, - onNotification: (message: NotificationMessage) => void, - onError: ((err: Error) => void) | undefined, + parentSubscription: (message: NotificationMessage) => void, context: SolidLdoDatasetContext, createWebsocket?: (address: string) => WebSocket, ) { - super(resource, onNotification, onError, context); + super(resource, parentSubscription, context); this.createWebsocket = createWebsocket ?? createWebsocketDefault; } - async open(): Promise { + async open(): Promise { try { const notificationChannel = await this.discoverNotificationChannel(); - return this.subscribeToWebsocket(notificationChannel); + await this.subscribeToWebsocket(notificationChannel); } catch (err) { if ( err instanceof Error && err.message.startsWith("Discovery did not succeed") ) { - return new UnsupportedNotificationError(this.resource.uri, err.message); + this.onNotificationError( + new UnsupportedNotificationError(this.resource.uri, err.message), + ); + } else { + this.onNotificationError( + UnexpectedResourceError.fromThrown(this.resource.uri, err), + ); } - return UnexpectedResourceError.fromThrown(this.resource.uri, err); + this.onClose(); } } - async discoverNotificationChannel(): Promise { + public async discoverNotificationChannel(): Promise { const client = new SubscriptionClient(this.context.fetch); return await client.subscribe( this.resource.uri, @@ -56,44 +71,61 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript ); } - async subscribeToWebsocket( + public async subscribeToWebsocket( notificationChannel: NotificationChannel, - ): Promise { - return new Promise((resolve) => { - let didResolve = false; - this.socket = this.createWebsocket( - notificationChannel.receiveFrom as string, + ): Promise { + this.socket = this.createWebsocket( + notificationChannel.receiveFrom as string, + ); + + this.socket.onopen = () => { + this.reconnectAttempts = 0; // Reset attempts on successful connection + this.isManualClose = false; // Reset manual close flag + }; + + this.socket.onmessage = (message) => { + const messageData = message.data.toString(); + // TODO uncompliant Pod error on misformatted message + this.onNotification(JSON.parse(messageData) as NotificationMessage); + }; + + this.socket.onclose = this.onClose.bind(this); + + this.socket.onerror = (err) => { + this.onNotificationError( + new UnexpectedResourceError(this.resource.uri, err.error), ); - this.socket.onmessage = (message) => { - const messageData = message.data.toString(); - // TODO uncompliant Pod error on misformatted message - this.onNotification(JSON.parse(messageData) as NotificationMessage); - }; - this.socket.onerror = (err) => { - if (!didResolve) { - resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); - } else { - this.onError?.(err.error); - } - }; - this.socket.onopen = () => { - didResolve = true; - resolve({ - isError: false, - type: "subscribeToNotificationSuccess", - uri: this.resource.uri, - }); - }; - }); + }; + return; } - async close(): Promise { + private onClose() { + if (!this.isManualClose) { + // Attempt to reconnect only if the disconnection was unintentional + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => { + this.open(); + }, this.reconnectInterval); + this.onNotificationError( + new DisconnectedAttemptingReconnectError( + this.resource.uri, + `Attempting to reconnect to Websocket for ${this.resource.uri}.`, + ), + ); + } else { + this.onNotificationError( + new DisconnectedNotAttemptingReconnectError( + this.resource.uri, + `Lost connection to websocket for ${this.resource.uri}.`, + ), + ); + } + } + } + + protected async close(): Promise { this.socket?.terminate(); - return { - type: "unsubscribeFromNotificationSuccess", - isError: false, - uri: this.resource.uri, - }; } } diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index 70382f7..f196c86 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,5 +1,12 @@ +import type { UnexpectedResourceError } from "../../../requester/results/error/ErrorResult"; import { ResourceError } from "../../../requester/results/error/ErrorResult"; +export type NotificationCallbackError = + | DisconnectedAttemptingReconnectError + | DisconnectedNotAttemptingReconnectError + | UnsupportedNotificationError + | UnexpectedResourceError; + /** * Indicates that the requested method for receiving notifications is not * supported by this Pod. @@ -7,3 +14,17 @@ import { ResourceError } from "../../../requester/results/error/ErrorResult"; export class UnsupportedNotificationError extends ResourceError { readonly type = "unsupportedNotificationError" as const; } + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedAttemptingReconnectError" as const; +} + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedNotAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedNotAttemptingReconnectError" as const; +} diff --git a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts deleted file mode 100644 index fd3cfcb..0000000 --- a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; - -/** - * Returned when a notification has been successfully subscribed to for a resource - */ -export interface SubscribeToNotificationSuccess extends ResourceSuccess { - type: "subscribeToNotificationSuccess"; -} diff --git a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts deleted file mode 100644 index 5aa97e7..0000000 --- a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; - -/** - * Returned when a notification has been successfully unsubscribed from for a resource - */ -export interface UnsubscribeToNotificationSuccess extends ResourceSuccess { - type: "unsubscribeFromNotificationSuccess"; -} diff --git a/packages/solid/src/types.ts b/packages/solid/src/types.ts index e87f63c..91141ea 100644 --- a/packages/solid/src/types.ts +++ b/packages/solid/src/types.ts @@ -1,12 +1,16 @@ +import type { ILdoDataset } from "@ldo/ldo"; import type { ResourceGetterOptions } from "./ResourceStore"; import type { Container } from "./resource/Container"; import type { Leaf } from "./resource/Leaf"; import type { ContainerUri, LeafUri } from "./util/uriTypes"; +import type { SolidLdoTransactionDataset } from "./SolidLdoTransactionDataset"; /** * A SolidLdoDataset provides methods for getting Solid resources. */ -export interface ISolidLdoDataset { +export interface ISolidLdoDataset extends ILdoDataset { + startTransaction(): SolidLdoTransactionDataset; + getResource(uri: ContainerUri, options?: ResourceGetterOptions): Container; getResource(uri: LeafUri, options?: ResourceGetterOptions): Leaf; getResource(uri: string, options?: ResourceGetterOptions): Leaf | Container; diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 22b60e7..e0c4433 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -18,6 +18,7 @@ import { import type { CreateSuccess } from "../src/requester/results/success/CreateSuccess"; import type { AggregateSuccess } from "../src/requester/results/success/SuccessResult"; import type { + IgnoredInvalidUpdateSuccess, UpdateDefaultGraphSuccess, UpdateSuccess, } from "../src/requester/results/success/UpdateSuccess"; @@ -175,7 +176,7 @@ describe("Integration", () => { app.stop(); process.env.JEST_WORKER_ID = previousJestId; process.env.NODE_ENV = previousNodeEnv; - const testDataPath = path.join(__dirname, "../data"); + const testDataPath = path.join(__dirname, "./data"); await fs.rm(testDataPath, { recursive: true, force: true }); }); @@ -1204,7 +1205,7 @@ describe("Integration", () => { expect(aggregateError.errors[0].type).toBe("unexpectedResourceError"); }); - it("errors when trying to update a container", async () => { + it("ignores update when trying to update a container", async () => { const badContainerQuad = createQuad( namedNode("http://example.org/#green-goblin"), namedNode("http://xmlns.com/foaf/0.1/name"), @@ -1214,13 +1215,15 @@ describe("Integration", () => { const transaction = solidLdoDataset.startTransaction(); transaction.add(badContainerQuad); const result = await transaction.commitToPod(); - expect(result.isError).toBe(true); - expect(result.type).toBe("aggregateError"); - const aggregateError = result as AggregateError< - UpdateResultError | InvalidUriError + expect(result.isError).toBe(false); + expect(result.type).toBe("aggregateSuccess"); + const aggregateSuccess = result as AggregateSuccess< + UpdateSuccess | IgnoredInvalidUpdateSuccess >; - expect(aggregateError.errors.length).toBe(1); - expect(aggregateError.errors[0].type === "invalidUriError").toBe(true); + expect(aggregateSuccess.results.length).toBe(1); + expect(aggregateSuccess.results[0].type).toBe( + "ignoredInvalidUpdateSuccess", + ); }); it("writes to the default graph without fetching", async () => { @@ -2035,8 +2038,7 @@ describe("Integration", () => { spidermanCallback, ); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + const subscriptionId = await resource.subscribeToNotifications(); expect(resource.isSubscribedToNotifications()).toBe(true); @@ -2060,10 +2062,7 @@ describe("Integration", () => { // Notification is not propogated after unsubscribe spidermanCallback.mockClear(); - const unsubscribeResponse = await resource.unsubscribeFromNotifications(); - expect(unsubscribeResponse.type).toBe( - "unsubscribeFromNotificationSuccess", - ); + await resource.unsubscribeFromNotifications(subscriptionId); expect(resource.isSubscribedToNotifications()).toBe(false); await authFetch(SAMPLE_DATA_URI, { method: "PATCH", @@ -2101,8 +2100,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await resource.subscribeToNotifications(); await authFetch(SAMPLE_DATA_URI, { method: "DELETE", @@ -2116,7 +2114,7 @@ describe("Integration", () => { expect(spidermanCallback).toHaveBeenCalledTimes(1); expect(containerCallback).toHaveBeenCalledTimes(1); - await resource.unsubscribeFromNotifications(); + await resource.unsubscribeFromAllNotifications(); }); it("handles notification when subscribed to a parent with a deleted child", async () => { @@ -2136,8 +2134,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await testContainer.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await testContainer.subscribeToNotifications(); await authFetch(SAMPLE_DATA_URI, { method: "DELETE", @@ -2151,7 +2148,7 @@ describe("Integration", () => { expect(spidermanCallback).toHaveBeenCalledTimes(1); expect(containerCallback).toHaveBeenCalledTimes(1); - await testContainer.unsubscribeFromNotifications(); + await testContainer.unsubscribeFromAllNotifications(); }); it("handles notification when subscribed to a parent with an added child", async () => { @@ -2171,8 +2168,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await testContainer.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await testContainer.subscribeToNotifications(); await authFetch(TEST_CONTAINER_URI, { method: "POST", @@ -2190,22 +2186,39 @@ describe("Integration", () => { expect(spidermanCallback).toHaveBeenCalledTimes(1); expect(containerCallback).toHaveBeenCalledTimes(1); - await testContainer.unsubscribeFromNotifications(); + await testContainer.unsubscribeFromAllNotifications(); }); it("returns an error when it cannot subscribe to a notification", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); + + await app.stop(); + await resource.subscribeToNotifications({ onNotificationError: onError }); + expect(onError).toHaveBeenCalledTimes(2); + await app.start(); + }); + + it("returns an error when the server doesnt support websockets", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); await app.stop(); + const disabledWebsocketsApp = await createApp( + path.join(__dirname, "./configs/server-config-without-websocket.json"), + ); + await disabledWebsocketsApp.start(); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("unexpectedResourceError"); + await resource.subscribeToNotifications({ onNotificationError: onError }); + expect(onError).toHaveBeenCalledTimes(2); + await disabledWebsocketsApp.stop(); await app.start(); }); - it("returns an error when the server doesnt support websockets", async () => { + it("attempts to reconnect multiple times before giving up.", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const onError = jest.fn(); await app.stop(); const disabledWebsocketsApp = await createApp( @@ -2213,8 +2226,19 @@ describe("Integration", () => { ); await disabledWebsocketsApp.start(); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("unsupportedNotificationError"); + await resource.subscribeToNotifications({ onNotificationError: onError }); + + // TODO: This is a bad test because of the wait. Instead inject better + // numbers into the websocket class. + await wait(35000); + + expect(onError).toHaveBeenCalledTimes(14); + expect(onError.mock.calls[1][0].type).toBe( + "disconnectedAttemptingReconnectError", + ); + expect(onError.mock.calls[13][0].type).toBe( + "disconnectedNotAttemptingReconnectError", + ); await disabledWebsocketsApp.stop(); await app.start(); @@ -2222,8 +2246,8 @@ describe("Integration", () => { it("causes no problems when unsubscribing when not subscribed", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); - const result = await resource.unsubscribeFromNotifications(); - expect(result.type).toBe("unsubscribeFromNotificationSuccess"); + await resource.unsubscribeFromAllNotifications(); + expect(resource.isSubscribedToNotifications()).toBe(false); }); }); }); diff --git a/packages/solid/test/Websocket2023NotificationSubscription.test.ts b/packages/solid/test/Websocket2023NotificationSubscription.test.ts index ddf92bb..1f97b0d 100644 --- a/packages/solid/test/Websocket2023NotificationSubscription.test.ts +++ b/packages/solid/test/Websocket2023NotificationSubscription.test.ts @@ -7,14 +7,12 @@ import type { NotificationChannel } from "@solid-notifications/types"; describe("Websocket2023NotificationSubscription", () => { it("returns an error when websockets have an error", async () => { const WebSocketMock: WebSocket = {} as WebSocket; - const onErrorMock = jest.fn(); const subscription = new Websocket2023NotificationSubscription( new Leaf("https://example.com", { fetch, } as unknown as SolidLdoDatasetContext), () => {}, - onErrorMock, {} as unknown as SolidLdoDatasetContext, () => WebSocketMock, ); @@ -24,24 +22,19 @@ describe("Websocket2023NotificationSubscription", () => { } as unknown as NotificationChannel); WebSocketMock.onopen?.({} as Event); - const subscriptionResult = await subPromise; - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await subPromise; WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); - - expect(onErrorMock).toHaveBeenCalled(); }); it("returns an error when websockets have an error at the beginning", async () => { const WebSocketMock: WebSocket = {} as WebSocket; - const onErrorMock = jest.fn(); const subscription = new Websocket2023NotificationSubscription( new Leaf("https://example.com", { fetch, } as unknown as SolidLdoDatasetContext), () => {}, - onErrorMock, {} as unknown as SolidLdoDatasetContext, () => WebSocketMock, ); @@ -50,8 +43,6 @@ describe("Websocket2023NotificationSubscription", () => { receiveFrom: "http://example.com", } as unknown as NotificationChannel); WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); - const subscriptionResult = await subPromise; - - expect(subscriptionResult.type).toBe("unexpectedResourceError"); + await subPromise; }); });