Merge pull request #69 from o-development/feat/type-index

Feat/type index
main
jaxoncreed 8 months ago committed by GitHub
commit c30aa1b94d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 104
      package-lock.json
  2. 4
      packages/cli/src/templates/context.ejs
  3. 1
      packages/ldo/src/index.ts
  4. 7
      packages/schema-converter-shex/src/context/JsonLdContextBuilder.ts
  5. 12
      packages/solid-react/src/useResource.ts
  6. 52
      packages/solid-react/src/useSubscribeToResource.ts
  7. 142
      packages/solid-react/test/Integration.test.tsx
  8. 1
      packages/solid-react/test/setUpServer.ts
  9. 3
      packages/solid-type-index/.eslintrc
  10. 1
      packages/solid-type-index/.gitignore
  11. 21
      packages/solid-type-index/LICENSE.txt
  12. 49
      packages/solid-type-index/README.md
  13. 6
      packages/solid-type-index/jest.config.js
  14. 2
      packages/solid-type-index/jest.setup.ts
  15. 52
      packages/solid-type-index/package.json
  16. 19
      packages/solid-type-index/src/.ldo/profile.context.ts
  17. 64
      packages/solid-type-index/src/.ldo/profile.schema.ts
  18. 19
      packages/solid-type-index/src/.ldo/profile.shapeTypes.ts
  19. 27
      packages/solid-type-index/src/.ldo/profile.typings.ts
  20. 50
      packages/solid-type-index/src/.ldo/typeIndex.context.ts
  21. 144
      packages/solid-type-index/src/.ldo/typeIndex.schema.ts
  22. 28
      packages/solid-type-index/src/.ldo/typeIndex.shapeTypes.ts
  23. 58
      packages/solid-type-index/src/.ldo/typeIndex.typings.ts
  24. 10
      packages/solid-type-index/src/.shapes/profile.shex
  25. 23
      packages/solid-type-index/src/.shapes/typeIndex.shex
  26. 7
      packages/solid-type-index/src/constants.ts
  27. 92
      packages/solid-type-index/src/getTypeIndex.ts
  28. 4
      packages/solid-type-index/src/index.ts
  29. 45
      packages/solid-type-index/src/react/useInstanceUris.ts
  30. 10
      packages/solid-type-index/src/react/useTypeIndexProfile.ts
  31. 35
      packages/solid-type-index/src/react/util/useSubscribeToUris.ts
  32. 207
      packages/solid-type-index/src/setTypeIndex.ts
  33. 14
      packages/solid-type-index/src/util/Options.ts
  34. 138
      packages/solid-type-index/test/General.test.tsx
  35. 153
      packages/solid-type-index/test/setUpServer.ts
  36. 52
      packages/solid-type-index/test/test-server/configs/components-config/unauthenticatedServer.json
  37. 9
      packages/solid-type-index/test/test-server/configs/solid-css-seed.json
  38. 13
      packages/solid-type-index/test/test-server/configs/template/wac/.acl.hbs
  39. 19
      packages/solid-type-index/test/test-server/configs/template/wac/profile/card.acl.hbs
  40. 7
      packages/solid-type-index/test/test-server/runServer.ts
  41. 39
      packages/solid-type-index/test/test-server/solidServer.helper.ts
  42. 8
      packages/solid-type-index/tsconfig.build.json
  43. 3
      packages/solid/src/SolidLdoDataset.ts
  44. 33
      packages/solid/src/SolidLdoTransactionDataset.ts
  45. 2
      packages/solid/src/index.ts
  46. 8
      packages/solid/src/requester/results/success/UpdateSuccess.ts
  47. 77
      packages/solid/src/resource/Resource.ts
  48. 133
      packages/solid/src/resource/notifications/NotificationSubscription.ts
  49. 104
      packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts
  50. 21
      packages/solid/src/resource/notifications/results/NotificationErrors.ts
  51. 8
      packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts
  52. 8
      packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts
  53. 6
      packages/solid/src/types.ts
  54. 84
      packages/solid/test/Integration.test.ts
  55. 13
      packages/solid/test/Websocket2023NotificationSubscription.test.ts

104
package-lock.json generated

@ -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,

@ -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 %>;

@ -7,3 +7,4 @@ export * from "./LdoBuilder";
export * from "./createLdoDataset";
import type { LdoBase as LdoBaseImport } from "./util";
export type LdoBase = LdoBaseImport;
export * from "./types";

@ -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]) {

@ -40,6 +40,7 @@ export function useResource(
options?: UseResourceOptions,
): Leaf | Container | undefined {
const { getResource } = useLdo();
const subscriptionIdRef = useRef<string | undefined>();
// 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]);

@ -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<Record<string, string>>({});
useEffect(() => {
const resources = uris.map((uri) => dataset.getResource(uri));
const previousSubscriptions = { ...currentlySubscribed.current };
Promise.all<void>(
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);
},
),
);
};
}, []);
}

@ -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 (
<div>
<p role="resource">
{resource.isSubscribedToNotifications().toString()}
</p>
<ul role="list">
{post.publisher.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
<button onClick={addPublisher}>Add Publisher</button>
<button onClick={() => setIsSubscribed(false)}>Unsubscribe</button>
</div>
);
};
@ -404,15 +412,17 @@ describe("Integration Tests", () => {
</UnauthenticatedSolidLdoProvider>,
);
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<string[]>([
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> <http://schema.org/publisher> <https://example.com/Publisher3> . }`,
headers: {
"Content-Type": "application/sparql-update",
},
});
}, []);
if (resource1.isLoading() || resource2.isLoading())
return <p>Loading</p>;
return (
<div>
<p role="resource1">
{resource1.isSubscribedToNotifications().toString()}
</p>
<p role="resource2">
{resource2.isSubscribedToNotifications().toString()}
</p>
<ul role="list">
{post.publisher.map((publisher) => {
return <li key={publisher["@id"]}>{publisher["@id"]}</li>;
})}
</ul>
<button onClick={addPublisher}>Add Publisher</button>
<button
onClick={() =>
setSubScribedUris([SAMPLE_DATA_URI, SAMPLE_BINARY_URI])
}
>
Subscribe More
</button>
<button onClick={() => setSubScribedUris([SAMPLE_BINARY_URI])}>
Subscribe Less
</button>
</div>
);
};
const { unmount } = render(
<UnauthenticatedSolidLdoProvider>
<NotificationTest />
</UnauthenticatedSolidLdoProvider>,
);
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();
});
});
});

@ -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;

@ -0,0 +1,3 @@
{
"extends": ["../../.eslintrc"]
}

@ -0,0 +1 @@
test/test-server/data

@ -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.

@ -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/).
[<img src="https://nlnet.nl/logo/banner.png" alt="nlnet foundation logo" width="300" />](https://nlnet.nl/)
[<img src="https://nlnet.nl/image/logos/NGI0Entrust_tag.svg" alt="NGI Zero Entrust Logo" width="300" />](https://nlnet.nl/)
## Liscense
MIT

@ -0,0 +1,6 @@
const sharedConfig = require("../../jest.config.js");
module.exports = {
...sharedConfig,
rootDir: "./",
testEnvironment: "jsdom",
};

@ -0,0 +1,2 @@
import "@inrupt/jest-jsdom-polyfills";
globalThis.fetch = async () => new Response();

@ -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"
}

@ -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,
},
};

@ -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)",
},
},
],
},
],
},
},
},
],
};

@ -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<TypeIndexProfile> = {
schema: profileSchema,
shape: "https://shaperepo.com/schemas/solidProfile#TypeIndexProfile",
context: profileContext,
};

@ -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;
}[];
}

@ -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,
},
},
},
};

@ -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"],
},
},
],
};

@ -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<TypeIndexDocument> = {
schema: typeIndexSchema,
shape: "https://shaperepo.com/schemas/solidProfile#TypeIndexDocument",
context: typeIndexContext,
};
/**
* TypeRegistration ShapeType
*/
export const TypeRegistrationShapeType: ShapeType<TypeRegistration> = {
schema: typeIndexSchema,
shape: "https://shaperepo.com/schemas/solidProfile#TypeRegistration",
context: typeIndexContext,
};

@ -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;
}[];
}

@ -0,0 +1,10 @@
PREFIX srs: <https://shaperepo.com/schemas/solidProfile#>
PREFIX solid: <http://www.w3.org/ns/solid/terms#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
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)" ;
}

@ -0,0 +1,23 @@
PREFIX srs: <https://shaperepo.com/schemas/solidProfile#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX solid: <http://www.w3.org/ns/solid/terms#>
PREFIX vcard: <http://www.w3.org/2006/vcard/ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
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." ;
}

@ -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";

@ -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<TypeRegistration[]> {
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<TypeIndexProfile> {
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<LeafUri[]> {
const { dataset } = guaranteeOptions(options);
const leafUris = new Set<LeafUri>();
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);
}

@ -0,0 +1,4 @@
export * from "./getTypeIndex";
export * from "./setTypeIndex";
export * from "./react/useInstanceUris";
export * from "./react/useTypeIndexProfile";

@ -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<LeafUri[]>([]);
const typeRegistrations = useMatchSubject(
TypeRegistrationShapeType,
RDF_TYPE,
TYPE_REGISTRATION,
);
useEffect(() => {
getInstanceUris(classUri, typeRegistrations, {
solidLdoDataset: dataset,
}).then(setLeafUris);
}, [typeRegistrations]);
return leafUris;
}

@ -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;
}

@ -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<Record<string, string>>({});
useEffect(() => {
const resources = uris.map((uri) => dataset.getResource(uri));
const previousSubscriptions = { ...currentlySubscribed.current };
Promise.all<void>(
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]);
}

@ -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<void> {
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;
}

@ -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 };
}

@ -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);
});
});

@ -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> <http://www.w3.org/ns/solid/terms#publicTypeIndex> <${PROFILE_CONTAINER}publicTypeIndex.ttl> ;
<http://www.w3.org/ns/solid/terms#privateTypeIndex> <${PROFILE_CONTAINER}privateTypeIndex.ttl> .`;
export const PUBLIC_TYPE_INDEX_TTL = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix vcard: <http://www.w3.org/2006/vcard/ns#>.
@prefix bk: <http://www.w3.org/2002/01/bookmark#>.
<>
a solid:TypeIndex ;
a solid:ListedDocument.
<#ab09fd> a solid:TypeRegistration;
solid:forClass vcard:AddressBook;
solid:instance <https://example.com/myPublicAddressBook.ttl>.
<#bq1r5e> a solid:TypeRegistration;
solid:forClass bk:Bookmark;
solid:instanceContainer <${ROOT_CONTAINER}myBookmarks/>.`;
export const PRIVATE_TYPE_INDEX_TTL = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix vcard: <http://www.w3.org/2006/vcard/ns#>.
@prefix bk: <http://www.w3.org/2002/01/bookmark#>.
<>
a solid:TypeIndex ;
a solid:UnlistedDocument.
<#ab09fd> a solid:TypeRegistration;
solid:forClass vcard:AddressBook;
solid:instance <https://example.com/myPrivateAddressBook.ttl>.
<#bq1r5e> a solid:TypeRegistration;
solid:forClass bk:Bookmark;
solid:instanceContainer <${ROOT_CONTAINER}myBookmarks/>.`;
export interface SetUpServerReturn {
authFetch: typeof fetch;
fetchMock: jest.Mock<
Promise<Response>,
[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: '<http://www.w3.org/ns/ldp#Container>; 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: '<http://www.w3.org/ns/ldp#Container>; 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;
}

@ -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"
}
}
]
}

@ -0,0 +1,9 @@
[
{
"email": "hello@example.com",
"password": "abc123",
"pods": [
{ "name": "example" }
]
}
]

@ -0,0 +1,13 @@
@prefix : <#>.
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
@prefix eve: <./>.
@prefix c: <./profile/card#>.
:ControlReadWrite
a acl:Authorization;
acl:accessTo eve:;
acl:agent c:me, <mailto:info@o.team>;
acl:agentClass foaf:Agent;
acl:default eve:;
acl:mode acl:Control, acl:Read, acl:Write.

@ -0,0 +1,19 @@
# ACL resource for the WebID profile document
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
# 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.

@ -0,0 +1,7 @@
import { createApp } from "./solidServer.helper";
async function run() {
const app = await createApp();
await app.start();
}
run();

@ -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<App> {
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;
}

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"lib": ["dom"]
},
"include": ["./src"]
}

@ -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
*/

@ -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<Quad>,
@ -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<UpdateSuccess | UpdateDefaultGraphSuccess, Leaf>
>
| AggregateError<UpdateResultError | InvalidUriError>
| AggregateError<UpdateResultError>
> {
const changes = this.getChanges();
const changesByGraph = splitChangesByGraph(changes);
@ -121,7 +129,7 @@ export class SolidLdoTransactionDataset
const results: [
GraphNode,
DatasetChanges<Quad>,
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<UpdateSuccess, Leaf> =>
result.type === "updateSuccess" ||
result.type === "updateDefaultGraphSuccess",
result.type === "updateDefaultGraphSuccess" ||
result.type === "ignoredInvalidUpdateSuccess",
),
};
}

@ -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";

@ -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";
}

@ -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<OpenSubscriptionResult> {
this.notificationSubscription = new Websocket2023NotificationSubscription(
this,
this.onNotification.bind(this),
onNotificationError,
this.context,
callbacks?: SubscriptionCallbacks,
): Promise<string> {
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<CloseSubscriptionResult> {
const result = await this.notificationSubscription?.close();
this.notificationSubscription = undefined;
return (
result ?? {
type: "unsubscribeFromNotificationSuccess",
isError: false,
uri: this.uri,
}
async unsubscribeFromNotifications(subscriptionId: string): Promise<void> {
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<void> {
return this.notificationSubscription.unsubscribeFromAllNotifications();
}
}

@ -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<string, SubscriptionCallbacks> = {};
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<string> {
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<void> {
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<void> {
await Promise.all(
Object.keys(this.subscriptions).map((id) =>
this.unsubscribeFromNotification(id),
),
);
}
/**
* ===========================================================================
* HELPERS
* ===========================================================================
*/
/**
* @internal
* Opens the subscription
*/
abstract open(): Promise<OpenSubscriptionResult>;
protected abstract open(): Promise<void>;
/**
* @internal
* Closes the subscription
*/
abstract close(): Promise<CloseSubscriptionResult>;
protected abstract close(): Promise<void>;
/**
* ===========================================================================
* 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");
}
}

@ -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<OpenSubscriptionResult> {
async open(): Promise<void> {
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<NotificationChannel> {
public async discoverNotificationChannel(): Promise<NotificationChannel> {
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<OpenSubscriptionResult> {
return new Promise<OpenSubscriptionResult>((resolve) => {
let didResolve = false;
): Promise<void> {
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) => {
if (!didResolve) {
resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err));
this.onNotificationError(
new UnexpectedResourceError(this.resource.uri, err.error),
);
};
return;
}
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.onError?.(err.error);
this.onNotificationError(
new DisconnectedNotAttemptingReconnectError(
this.resource.uri,
`Lost connection to websocket for ${this.resource.uri}.`,
),
);
}
}
};
this.socket.onopen = () => {
didResolve = true;
resolve({
isError: false,
type: "subscribeToNotificationSuccess",
uri: this.resource.uri,
});
};
});
}
async close(): Promise<CloseSubscriptionResult> {
protected async close(): Promise<void> {
this.socket?.terminate();
return {
type: "unsubscribeFromNotificationSuccess",
isError: false,
uri: this.resource.uri,
};
}
}

@ -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;
}

@ -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";
}

@ -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";
}

@ -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;

@ -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);
});
});
});

@ -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;
});
});

Loading…
Cancel
Save