From 6915dcadce113dc3a45f98782df0318ae5f28bb1 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Sun, 5 Jan 2025 14:56:38 -0500 Subject: [PATCH 01/11] init solid-type-index package --- packages/solid-type-index/.eslintrc | 3 + packages/solid-type-index/.gitignore | 1 + packages/solid-type-index/LICENSE.txt | 21 + packages/solid-type-index/README.md | 14 + packages/solid-type-index/jest.config.js | 6 + packages/solid-type-index/jest.setup.ts | 2 + packages/solid-type-index/package.json | 50 ++ .../src/.ldo/profile.context.ts | 19 + .../src/.ldo/profile.schema.ts | 64 +++ .../src/.ldo/profile.shapeTypes.ts | 19 + .../src/.ldo/profile.typings.ts | 27 ++ .../src/.ldo/typeIndex.context.ts | 47 ++ .../src/.ldo/typeIndex.schema.ts | 144 ++++++ .../src/.ldo/typeIndex.shapeTypes.ts | 28 ++ .../src/.ldo/typeIndex.typings.ts | 58 +++ .../solid-type-index/src/.shapes/profile.shex | 10 + .../src/.shapes/typeIndex.shex | 23 + packages/solid-type-index/src/index.ts | 0 .../src/react/useAddToTypeIndex.ts | 0 .../solid-type-index/src/react/useProfile.ts | 0 .../src/react/useRemoveFromTypeIndex.ts | 0 .../solid-type-index/src/react/useType.ts | 0 .../test/.ldo/post.context.ts | 32 ++ .../solid-type-index/test/.ldo/post.schema.ts | 155 +++++++ .../test/.ldo/post.shapeTypes.ts | 19 + .../test/.ldo/post.typings.ts | 45 ++ .../test/Integration.test.tsx | 431 ++++++++++++++++++ packages/solid-type-index/test/setUpServer.ts | 110 +++++ .../unauthenticatedServer.json | 52 +++ .../test-server/configs/solid-css-seed.json | 9 + .../test-server/configs/template/wac/.acl.hbs | 13 + .../configs/template/wac/profile/card.acl.hbs | 19 + .../test/test-server/runServer.ts | 7 + .../test/test-server/solidServer.helper.ts | 39 ++ packages/solid-type-index/tsconfig.build.json | 8 + 35 files changed, 1475 insertions(+) create mode 100644 packages/solid-type-index/.eslintrc create mode 100644 packages/solid-type-index/.gitignore create mode 100644 packages/solid-type-index/LICENSE.txt create mode 100644 packages/solid-type-index/README.md create mode 100644 packages/solid-type-index/jest.config.js create mode 100644 packages/solid-type-index/jest.setup.ts create mode 100644 packages/solid-type-index/package.json create mode 100644 packages/solid-type-index/src/.ldo/profile.context.ts create mode 100644 packages/solid-type-index/src/.ldo/profile.schema.ts create mode 100644 packages/solid-type-index/src/.ldo/profile.shapeTypes.ts create mode 100644 packages/solid-type-index/src/.ldo/profile.typings.ts create mode 100644 packages/solid-type-index/src/.ldo/typeIndex.context.ts create mode 100644 packages/solid-type-index/src/.ldo/typeIndex.schema.ts create mode 100644 packages/solid-type-index/src/.ldo/typeIndex.shapeTypes.ts create mode 100644 packages/solid-type-index/src/.ldo/typeIndex.typings.ts create mode 100644 packages/solid-type-index/src/.shapes/profile.shex create mode 100644 packages/solid-type-index/src/.shapes/typeIndex.shex create mode 100644 packages/solid-type-index/src/index.ts create mode 100644 packages/solid-type-index/src/react/useAddToTypeIndex.ts create mode 100644 packages/solid-type-index/src/react/useProfile.ts create mode 100644 packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts create mode 100644 packages/solid-type-index/src/react/useType.ts create mode 100644 packages/solid-type-index/test/.ldo/post.context.ts create mode 100644 packages/solid-type-index/test/.ldo/post.schema.ts create mode 100644 packages/solid-type-index/test/.ldo/post.shapeTypes.ts create mode 100644 packages/solid-type-index/test/.ldo/post.typings.ts create mode 100644 packages/solid-type-index/test/Integration.test.tsx create mode 100644 packages/solid-type-index/test/setUpServer.ts create mode 100644 packages/solid-type-index/test/test-server/configs/components-config/unauthenticatedServer.json create mode 100644 packages/solid-type-index/test/test-server/configs/solid-css-seed.json create mode 100644 packages/solid-type-index/test/test-server/configs/template/wac/.acl.hbs create mode 100644 packages/solid-type-index/test/test-server/configs/template/wac/profile/card.acl.hbs create mode 100644 packages/solid-type-index/test/test-server/runServer.ts create mode 100644 packages/solid-type-index/test/test-server/solidServer.helper.ts create mode 100644 packages/solid-type-index/tsconfig.build.json 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..9f6f385 --- /dev/null +++ b/packages/solid-type-index/README.md @@ -0,0 +1,14 @@ +# @ldo/solid-type-index + +Alpha + +// TODO: Write readme + +## 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..7970b8a --- /dev/null +++ b/packages/solid-type-index/package.json @@ -0,0 +1,50 @@ +{ + "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", + "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" + }, + "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..534da7b --- /dev/null +++ b/packages/solid-type-index/src/.ldo/profile.context.ts @@ -0,0 +1,19 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * profileContext: JSONLD Context for profile + * ============================================================================= + */ +export const profileContext: ContextDefinition = { + 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..d0dbefd --- /dev/null +++ b/packages/solid-type-index/src/.ldo/typeIndex.context.ts @@ -0,0 +1,47 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * typeIndexContext: JSONLD Context for typeIndex + * ============================================================================= + */ +export const typeIndexContext: ContextDefinition = { + 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/index.ts b/packages/solid-type-index/src/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/src/react/useAddToTypeIndex.ts b/packages/solid-type-index/src/react/useAddToTypeIndex.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/src/react/useProfile.ts b/packages/solid-type-index/src/react/useProfile.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts b/packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/src/react/useType.ts b/packages/solid-type-index/src/react/useType.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/test/.ldo/post.context.ts b/packages/solid-type-index/test/.ldo/post.context.ts new file mode 100644 index 0000000..5cb3a91 --- /dev/null +++ b/packages/solid-type-index/test/.ldo/post.context.ts @@ -0,0 +1,32 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * postContext: JSONLD Context for post + * ============================================================================= + */ +export const postContext: ContextDefinition = { + type: { + "@id": "@type", + }, + SocialMediaPosting: "http://schema.org/SocialMediaPosting", + CreativeWork: "http://schema.org/CreativeWork", + Thing: "http://schema.org/Thing", + articleBody: { + "@id": "http://schema.org/articleBody", + "@type": "http://www.w3.org/2001/XMLSchema#string", + }, + uploadDate: { + "@id": "http://schema.org/uploadDate", + "@type": "http://www.w3.org/2001/XMLSchema#date", + }, + image: { + "@id": "http://schema.org/image", + "@type": "@id", + }, + publisher: { + "@id": "http://schema.org/publisher", + "@type": "@id", + "@container": "@set", + }, +}; diff --git a/packages/solid-type-index/test/.ldo/post.schema.ts b/packages/solid-type-index/test/.ldo/post.schema.ts new file mode 100644 index 0000000..39e8b63 --- /dev/null +++ b/packages/solid-type-index/test/.ldo/post.schema.ts @@ -0,0 +1,155 @@ +import { Schema } from "shexj"; + +/** + * ============================================================================= + * postSchema: ShexJ Schema for post + * ============================================================================= + */ +export const postSchema: Schema = { + type: "Schema", + shapes: [ + { + id: "https://example.com/PostSh", + 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://schema.org/SocialMediaPosting", + "http://schema.org/CreativeWork", + "http://schema.org/Thing", + ], + }, + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/articleBody", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#string", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "articleBody", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The actual body of the article. ", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/uploadDate", + valueExpr: { + type: "NodeConstraint", + datatype: "http://www.w3.org/2001/XMLSchema#date", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "uploadDate", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "Date when this media object was uploaded to this site.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/image", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + min: 0, + max: 1, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "image", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A media object that encodes this CreativeWork. This property is a synonym for encoding.", + }, + }, + ], + }, + { + type: "TripleConstraint", + predicate: "http://schema.org/publisher", + valueExpr: { + type: "NodeConstraint", + nodeKind: "iri", + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "publisher", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: "The publisher of the creative work.", + }, + }, + ], + }, + ], + }, + annotations: [ + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#label", + object: { + value: "SocialMediaPost", + }, + }, + { + type: "Annotation", + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { + value: + "A post to a social media platform, including blog posts, tweets, Facebook posts, etc.", + }, + }, + ], + }, + }, + ], +}; diff --git a/packages/solid-type-index/test/.ldo/post.shapeTypes.ts b/packages/solid-type-index/test/.ldo/post.shapeTypes.ts new file mode 100644 index 0000000..4c50683 --- /dev/null +++ b/packages/solid-type-index/test/.ldo/post.shapeTypes.ts @@ -0,0 +1,19 @@ +import { ShapeType } from "@ldo/ldo"; +import { postSchema } from "./post.schema"; +import { postContext } from "./post.context"; +import { PostSh } from "./post.typings"; + +/** + * ============================================================================= + * LDO ShapeTypes post + * ============================================================================= + */ + +/** + * PostSh ShapeType + */ +export const PostShShapeType: ShapeType = { + schema: postSchema, + shape: "https://example.com/PostSh", + context: postContext, +}; diff --git a/packages/solid-type-index/test/.ldo/post.typings.ts b/packages/solid-type-index/test/.ldo/post.typings.ts new file mode 100644 index 0000000..1425a9a --- /dev/null +++ b/packages/solid-type-index/test/.ldo/post.typings.ts @@ -0,0 +1,45 @@ +import { ContextDefinition } from "jsonld"; + +/** + * ============================================================================= + * Typescript Typings for post + * ============================================================================= + */ + +/** + * PostSh Type + */ +export interface PostSh { + "@id"?: string; + "@context"?: ContextDefinition; + type: + | { + "@id": "SocialMediaPosting"; + } + | { + "@id": "CreativeWork"; + } + | { + "@id": "Thing"; + }; + /** + * The actual body of the article. + */ + articleBody?: string; + /** + * Date when this media object was uploaded to this site. + */ + uploadDate: string; + /** + * A media object that encodes this CreativeWork. This property is a synonym for encoding. + */ + image?: { + "@id": string; + }; + /** + * The publisher of the creative work. + */ + publisher: { + "@id": string; + }[]; +} diff --git a/packages/solid-type-index/test/Integration.test.tsx b/packages/solid-type-index/test/Integration.test.tsx new file mode 100644 index 0000000..8c8ae0b --- /dev/null +++ b/packages/solid-type-index/test/Integration.test.tsx @@ -0,0 +1,431 @@ +import React, { useCallback, useEffect, useState } from "react"; +import type { FunctionComponent } from "react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { + SAMPLE_BINARY_URI, + SAMPLE_DATA_URI, + SERVER_DOMAIN, + setUpServer, +} from "./setUpServer"; +import { UnauthenticatedSolidLdoProvider } from "../src/UnauthenticatedSolidLdoProvider"; +import { useResource } from "../src/useResource"; +import { useRootContainerFor } from "../src/useRootContainer"; +import { useLdo } from "../src/SolidLdoProvider"; +import { PostShShapeType } from "./.ldo/post.shapeTypes"; +import type { PostSh } from "./.ldo/post.typings"; +import { useSubject } from "../src/useSubject"; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +describe("Integration Tests", () => { + setUpServer(); + + /** + * =========================================================================== + * useResource + * =========================================================================== + */ + describe("useResource", () => { + it("Fetches a resource and indicates it is loading while doing so", async () => { + const UseResourceTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI); + if (resource?.isLoading()) return

Loading

; + return

{resource.status.type}

; + }; + render( + + + , + ); + await screen.findByText("Loading"); + const resourceStatus = await screen.findByRole("status"); + expect(resourceStatus.innerHTML).toBe("dataReadSuccess"); + }); + + it("returns undefined when no uri is provided, then rerenders when one is", async () => { + const UseResourceUndefinedTest: FunctionComponent = () => { + const [uri, setUri] = useState(undefined); + const resource = useResource(uri, { suppressInitialRead: true }); + if (!resource) + return ( +
+

Undefined

+ +
+ ); + return

{resource.status.type}

; + }; + render( + + + , + ); + await screen.findByText("Undefined"); + fireEvent.click(screen.getByText("Next")); + const resourceStatus = await screen.findByRole("status"); + expect(resourceStatus.innerHTML).toBe("unfetched"); + }); + + it("Reloads the data on mount", async () => { + const ReloadTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI, { reloadOnMount: true }); + if (resource?.isLoading()) return

Loading

; + return

{resource.status.type}

; + }; + const ReloadParent: FunctionComponent = () => { + const [showComponent, setShowComponent] = useState(true); + return ( +
+ + {showComponent ? :

Hidden

} +
+ ); + }; + render( + + + , + ); + await screen.findByText("Loading"); + const resourceStatus1 = await screen.findByRole("status"); + expect(resourceStatus1.innerHTML).toBe("dataReadSuccess"); + fireEvent.click(screen.getByText("Show Component")); + await screen.findByText("Hidden"); + fireEvent.click(screen.getByText("Show Component")); + await screen.findByText("Loading"); + const resourceStatus2 = await screen.findByRole("status", undefined, { + timeout: 5000, + }); + expect(resourceStatus2.innerHTML).toBe("dataReadSuccess"); + }); + + it("handles swapping to a new resource", async () => { + const SwapResourceTest: FunctionComponent = () => { + const [uri, setUri] = useState(SAMPLE_DATA_URI); + const resource = useResource(uri); + if (resource?.isLoading()) return

Loading

; + return ( +
+

{resource.status.type}

+ +
+ ); + }; + render( + + + , + ); + await screen.findByText("Loading"); + const resourceStatus1 = await screen.findByRole("status"); + expect(resourceStatus1.innerHTML).toBe("dataReadSuccess"); + fireEvent.click(screen.getByText("Update URI")); + await screen.findByText("Loading"); + const resourceStatus2 = await screen.findByRole("status"); + expect(resourceStatus2.innerHTML).toBe("binaryReadSuccess"); + }); + }); + + /** + * =========================================================================== + * useRootContainer + * =========================================================================== + */ + describe("useRootContainer", () => { + it("gets the root container for a sub-resource", async () => { + const RootContainerTest: FunctionComponent = () => { + const rootContainer = useRootContainerFor(SAMPLE_DATA_URI, { + suppressInitialRead: true, + }); + return rootContainer ?

{rootContainer?.uri}

: <>; + }; + render( + + + , + ); + const container = await screen.findByRole("root"); + expect(container.innerHTML).toBe(SERVER_DOMAIN); + }); + + it("returns undefined when a URI is not provided", async () => { + const RootContainerTest: FunctionComponent = () => { + const rootContainer = useRootContainerFor(undefined, { + suppressInitialRead: true, + }); + return rootContainer ? ( +

{rootContainer?.uri}

+ ) : ( +

Undefined

+ ); + }; + render( + + + , + ); + const container = await screen.findByRole("undefined"); + expect(container.innerHTML).toBe("Undefined"); + }); + }); + + /** + * =========================================================================== + * useLdoMethods + * =========================================================================== + */ + describe("useLdoMethods", () => { + it("uses get subject to get a linked data object", async () => { + const GetSubjectTest: FunctionComponent = () => { + const [subject, setSubject] = useState(); + const { getSubject } = useLdo(); + useEffect(() => { + const someSubject = getSubject( + PostShShapeType, + "https://example.com/subject", + ); + setSubject(someSubject); + }, []); + return subject ?

{subject["@id"]}

: <>; + }; + render( + + + , + ); + const container = await screen.findByRole("subject"); + expect(container.innerHTML).toBe("https://example.com/subject"); + }); + + it("uses createData to create a new data object", async () => { + const GetSubjectTest: FunctionComponent = () => { + const [subject, setSubject] = useState(); + const { createData, getResource } = useLdo(); + useEffect(() => { + const someSubject = createData( + PostShShapeType, + "https://example.com/subject", + getResource("https://example.com/"), + ); + someSubject.articleBody = "Cool Article"; + setSubject(someSubject); + }, []); + return subject ?

{subject.articleBody}

: <>; + }; + render( + + + , + ); + const container = await screen.findByRole("subject"); + expect(container.innerHTML).toBe("Cool Article"); + }); + }); + + /** + * =========================================================================== + * useSubject + * =========================================================================== + */ + describe("useSubject", () => { + it("renders the article body from the useSubject value", async () => { + const UseSubjectTest: FunctionComponent = () => { + useResource(SAMPLE_DATA_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + + return

{post.articleBody}

; + }; + render( + + + , + ); + + await screen.findByText("test"); + }); + + it("renders the array value from the useSubject value", async () => { + const UseSubjectTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + if (resource.isLoading() || !post) return

loading

; + + return ( +
+

{post.publisher[0]["@id"]}

+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+
+ ); + }; + render( + + + , + ); + + const single = await screen.findByRole("single"); + expect(single.innerHTML).toBe("https://example.com/Publisher1"); + 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"); + }); + + it("returns undefined in the subject URI is undefined", async () => { + const UseSubjectTest: FunctionComponent = () => { + useResource(SAMPLE_DATA_URI, { suppressInitialRead: true }); + const post = useSubject(PostShShapeType, undefined); + + return ( +

+ {post === undefined ? "Undefined" : "Not Undefined"} +

+ ); + }; + render( + + + , + ); + + const article = await screen.findByRole("article"); + expect(article.innerHTML).toBe("Undefined"); + }); + + it("returns nothing if a symbol key is provided", async () => { + const UseSubjectTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + if (resource.isLoading() || !post) return

loading

; + + return

{typeof post[Symbol.hasInstance]}

; + }; + render( + + + , + ); + + const article = await screen.findByRole("value"); + expect(article.innerHTML).toBe("undefined"); + }); + + it("returns an id if an id key is provided", async () => { + const UseSubjectTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + if (resource.isLoading() || !post) return

loading

; + + return

{post["@id"]}

; + }; + render( + + + , + ); + + const article = await screen.findByRole("value"); + expect(article.innerHTML).toBe(`${SAMPLE_DATA_URI}#Post1`); + }); + + it("does not set a value if a value is attempted to be set", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const UseSubjectTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI); + const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); + if (resource.isLoading() || !post) return

loading

; + + return ( +
+

{post.articleBody}

+ +
+ ); + }; + render( + + + , + ); + + const article = await screen.findByRole("value"); + expect(article.innerHTML).toBe(`test`); + fireEvent.click(screen.getByText("Attempt Change")); + expect(article.innerHTML).not.toBe("bad"); + expect(warn).toHaveBeenCalledWith( + "You've attempted to set a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.", + ); + warn.mockReset(); + }); + + it("rerenders when asked to subscribe to a resource", async () => { + const NotificationTest: FunctionComponent = () => { + const resource = useResource(SAMPLE_DATA_URI, { subscribe: true }); + 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 (resource.isLoading() || !post) return

loading

; + + return ( +
+
    + {post.publisher.map((publisher) => { + return
  • {publisher["@id"]}
  • ; + })} +
+ +
+ ); + }; + const { unmount } = render( + + + , + ); + + 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)); + }); + + // 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", + ); + + unmount(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + }); + }); +}); diff --git a/packages/solid-type-index/test/setUpServer.ts b/packages/solid-type-index/test/setUpServer.ts new file mode 100644 index 0000000..0551ccf --- /dev/null +++ b/packages/solid-type-index/test/setUpServer.ts @@ -0,0 +1,110 @@ +import type { ContainerUri, LeafUri } from "@ldo/solid"; +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 TEST_CONTAINER_SLUG = "test_ldo/"; +export const TEST_CONTAINER_URI = + `${ROOT_CONTAINER}${TEST_CONTAINER_SLUG}` as ContainerUri; +export const SAMPLE_DATA_URI = `${TEST_CONTAINER_URI}sample.ttl` as LeafUri; +export const SAMPLE2_DATA_SLUG = "sample2.ttl"; +export const SAMPLE2_DATA_URI = + `${TEST_CONTAINER_URI}${SAMPLE2_DATA_SLUG}` as LeafUri; +export const SAMPLE_BINARY_URI = `${TEST_CONTAINER_URI}sample.txt` as LeafUri; +export const SAMPLE2_BINARY_SLUG = `sample2.txt`; +export const SAMPLE2_BINARY_URI = + `${TEST_CONTAINER_URI}${SAMPLE2_BINARY_SLUG}` as LeafUri; +export const SAMPLE_CONTAINER_URI = + `${TEST_CONTAINER_URI}sample_container/` as ContainerUri; +export const EXAMPLE_POST_TTL = `@prefix schema: . + +<#Post1> + a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting ; + schema:image ; + schema:articleBody "test" ; + schema:publisher , .`; +export const TEST_CONTAINER_TTL = `@prefix dc: . +@prefix ldp: . +@prefix posix: . +@prefix xsd: . + +<> "sample.txt"; + a ldp:Container, ldp:BasicContainer, ldp:Resource; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. + a ldp:Resource, ; + dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. +<> posix:mtime 1697810234; + ldp:contains , . + posix:mtime 1697810234; + posix:size 522. + posix:mtime 1697810234; + posix:size 10.`; + +export interface SetUpServerReturn { + authFetch: typeof fetch; + fetchMock: jest.Mock< + Promise, + [input: RequestInfo | URL, init?: RequestInit | undefined] + >; +} + +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); + // Create a new document called sample.ttl + await s.authFetch(ROOT_CONTAINER, { + method: "POST", + headers: { + link: '; rel="type"', + slug: TEST_CONTAINER_SLUG, + }, + }); + await Promise.all([ + s.authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "sample.ttl" }, + body: EXAMPLE_POST_TTL, + }), + s.authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/plain", slug: "sample.txt" }, + body: "some text.", + }), + ]); + }); + + afterEach(async () => { + await Promise.all([ + s.authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }), + s.authFetch(SAMPLE2_DATA_URI, { + method: "DELETE", + }), + s.authFetch(SAMPLE_BINARY_URI, { + method: "DELETE", + }), + s.authFetch(SAMPLE2_BINARY_URI, { + method: "DELETE", + }), + s.authFetch(SAMPLE_CONTAINER_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..5dd45d8 --- /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_001, + 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 From e2589eb85fc3856e840a0914083e19e2e419e6ec Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Sat, 11 Jan 2025 23:43:57 -0500 Subject: [PATCH 02/11] Before refactor to remove return from open and close --- .../solid-type-index/src/react/useProfile.ts | 0 .../solid-type-index/src/react/useType.ts | 0 .../src/react/useTypeIndex.ts | 31 ++++++ .../src/react/useTypeIndexProfile.ts | 10 ++ packages/solid/src/resource/Resource.ts | 72 ++++++++----- .../notifications/NotificationSubscription.ts | 101 ++++++++++++++++-- .../Websocket2023NotificationSubscription.ts | 94 +++++++++++----- .../results/NotificationErrors.ts | 26 +++++ .../results/SubscribeToNotificationSuccess.ts | 1 + .../UnsubscribeFromNotificationSuccess.ts | 1 + packages/solid/test/Integration.test.ts | 17 +-- ...socket2023NotificationSubscription.test.ts | 8 +- 12 files changed, 286 insertions(+), 75 deletions(-) delete mode 100644 packages/solid-type-index/src/react/useProfile.ts delete mode 100644 packages/solid-type-index/src/react/useType.ts create mode 100644 packages/solid-type-index/src/react/useTypeIndex.ts create mode 100644 packages/solid-type-index/src/react/useTypeIndexProfile.ts diff --git a/packages/solid-type-index/src/react/useProfile.ts b/packages/solid-type-index/src/react/useProfile.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/solid-type-index/src/react/useType.ts b/packages/solid-type-index/src/react/useType.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/solid-type-index/src/react/useTypeIndex.ts b/packages/solid-type-index/src/react/useTypeIndex.ts new file mode 100644 index 0000000..122c069 --- /dev/null +++ b/packages/solid-type-index/src/react/useTypeIndex.ts @@ -0,0 +1,31 @@ +import type { LeafUri } from "@ldo/solid"; +import { useTypeIndexProfile } from "./useTypeIndexProfile"; +import { useEffect, useMemo } from "react"; +import { useLdo } from "@ldo/solid-react"; + +export function useTypeIndex(classUri: string): Promise { + const { dataset } = useLdo(); + + const profile = useTypeIndexProfile(); + const typeIndexUris: string[] = useMemo(() => { + const uris: string[] = []; + profile?.privateTypeIndex?.forEach((indexNode) => { + uris.push(indexNode["@id"]); + }); + profile?.publicTypeIndex?.forEach((indexNode) => { + uris.push(indexNode["@id"]); + }); + }, [profile]); + + useEffect(() => { + const resources = typeIndexUris.map((uri) => dataset.getResource(uri)); + resources.forEach((resource) => { + resource.readIfUnfetched(); + resource.subscribeToNotifications(); + }); + + return () => { + resources.forEach((resource) => resource.unsubscribeFromNotifications()); + } + }, [typeIndexUris]); +} 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/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index 5810428..c21dc89 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -37,9 +37,9 @@ 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, + SubscribeResult, + UnsubscribeResult, } from "./notifications/NotificationSubscription"; import { Websocket2023NotificationSubscription } from "./notifications/Websocket2023NotificationSubscription"; import type { NotificationMessage } from "./notifications/NotificationMessage"; @@ -111,7 +111,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 +119,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, + ); } /** @@ -334,7 +339,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ``` */ isSubscribedToNotifications(): boolean { - return !!this.notificationSubscription; + return this.notificationSubscription.isSubscribedToNotifications(); } /** @@ -735,7 +740,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 SubscriptionResult * * @example * ```typescript @@ -754,19 +759,22 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ); * * // Subscribe - * const subscriptionResult = await testContainer.subscribeToNotifications(); + * const subscriptionResult = 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 ait for a file to be changed on the Pod. */ async subscribeToNotifications( + onNotification?: (message: NotificationMessage) => void, onNotificationError?: (err: Error) => void, - ): Promise { - this.notificationSubscription = new Websocket2023NotificationSubscription( - this, - this.onNotification.bind(this), + ): Promise { + return await this.notificationSubscription.subscribeToNotifications({ + onNotification, onNotificationError, - this.context, - ); - return await this.notificationSubscription.open(); + }); } /** @@ -802,22 +810,38 @@ 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 subscriptionResult = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromNotifications( + * subscriptionResult.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.unsubscribeFromNotifications( + * subscriptionResult.subscriptionId + * ); + * ``` + */ + 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..1c9113e 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -2,50 +2,129 @@ import type { UnexpectedResourceError } from "../../requester/results/error/Erro import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; import type { Resource } from "../Resource"; import type { NotificationMessage } from "./NotificationMessage"; -import type { UnsupportedNotificationError } from "./results/NotificationErrors"; +import type { + NotificationCallbackError, + UnsupportedNotificationError, +} from "./results/NotificationErrors"; import type { SubscribeToNotificationSuccess } from "./results/SubscribeToNotificationSuccess"; import type { UnsubscribeToNotificationSuccess } from "./results/UnsubscribeFromNotificationSuccess"; +import { v4 } from "uuid"; -export type OpenSubscriptionResult = +export type SubscribeResult = | SubscribeToNotificationSuccess | UnsupportedNotificationError | UnexpectedResourceError; -export type CloseSubscriptionResult = +export type UnsubscribeResult = | UnsubscribeToNotificationSuccess | UnexpectedResourceError; +export type OpenResult = + | { type: "success" } + | UnsupportedNotificationError + | UnexpectedResourceError; + +export type CloseResult = { type: "success" } | UnexpectedResourceError; + +export interface SubscriptionCallbacks { + onNotification?: (message: NotificationMessage) => void; + // TODO: make notification errors more specific + onNotificationError?: (error: Error) => void; +} + /** * @internal * Abstract class for notification subscription methods. */ 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 = {}; + protected 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; + } + + protected onNotification(message: NotificationMessage): void { + this.parentSubscription(message); + Object.values(this.subscriptions).forEach(({ onNotification }) => { + onNotification?.(message); + }); + } + + protected onNotificationError(message: NotificationCallbackError): void { + Object.values(this.subscriptions).forEach(({ onNotificationError }) => { + onNotificationError?.(message); + }); + if (message.type === "disconnectedNotAttemptingReconnectError") { + this.isOpen = false; + } + } + + async subscribeToNotifications( + subscriptionCallbacks: SubscriptionCallbacks, + ): Promise { + if (!this.isOpen) { + const openResult = await this.open(); + if (openResult.type !== "success") return openResult; + this.isOpen = true; + } + const subscriptionId = v4(); + this.subscriptions[subscriptionId] = subscriptionCallbacks; + return { + isError: false, + type: "subscribeToNotificationSuccess", + uri: this.resource.uri, + subscriptionId, + }; + } + + async unsubscribeFromNotification( + subscriptionId: string, + ): Promise { + delete this.subscriptions[subscriptionId]; + if (Object.keys(this.subscriptions).length === 0) { + const closeResult = await this.close(); + if (closeResult.type !== "success") return closeResult; + this.isOpen = false; + } + return { + isError: false, + type: "unsubscribeFromNotificationSuccess", + uri: this.resource.uri, + subscriptionId, + }; + } + + async unsubscribeFromAllNotifications(): Promise { + return Promise.all( + Object.keys(this.subscriptions).map((id) => + this.unsubscribeFromNotification(id), + ), + ); + } + /** * @internal * Opens the subscription */ - abstract open(): Promise; + protected abstract open(): Promise; /** * @internal * Closes the subscription */ - abstract close(): Promise; + protected abstract close(): Promise; } diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts index 5434b7b..420bd6e 100644 --- a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -1,12 +1,13 @@ import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; -import type { - CloseSubscriptionResult, - OpenSubscriptionResult, -} from "./NotificationSubscription"; +import type { CloseResult, OpenResult } 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,18 +23,27 @@ 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 = 10; + 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); @@ -48,7 +58,7 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript } } - async discoverNotificationChannel(): Promise { + public async discoverNotificationChannel(): Promise { const client = new SubscriptionClient(this.context.fetch); return await client.subscribe( this.resource.uri, @@ -56,43 +66,75 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript ); } - async subscribeToWebsocket( + public async subscribeToWebsocket( notificationChannel: NotificationChannel, - ): Promise { - return new Promise((resolve) => { + ): Promise { + return new Promise((resolve) => { let didResolve = false; 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 + didResolve = true; + resolve({ + type: "success", + }); + }; + 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)); } else { - this.onError?.(err.error); + this.onNotificationError( + new UnexpectedResourceError(this.resource.uri, err.error), + ); } }; - this.socket.onopen = () => { - didResolve = true; - resolve({ - isError: false, - type: "subscribeToNotificationSuccess", - uri: this.resource.uri, - }); - }; }); } - async close(): Promise { + private onClose() { + if (!this.isManualClose) { + // Attempt to reconnect only if the disconnection was unintentional + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const backoffTime = Math.min( + this.reconnectInterval * this.reconnectAttempts, + 30000, + ); // Cap backoff at 30 seconds + setTimeout(this.open, backoffTime); + 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, + type: "success", }; } } diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index 70382f7..dcde3bb 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,3 +1,4 @@ +import type { UnexpectedResourceError } from "../../../requester/results/error/ErrorResult"; import { ResourceError } from "../../../requester/results/error/ErrorResult"; /** @@ -7,3 +8,28 @@ import { ResourceError } from "../../../requester/results/error/ErrorResult"; export class UnsupportedNotificationError extends ResourceError { readonly type = "unsupportedNotificationError" as const; } + +/** + * ============================================================================= + * CALLBACK ERRORS + * ============================================================================= + */ + +export type NotificationCallbackError = + | DisconnectedAttemptingReconnectError + | DisconnectedNotAttemptingReconnectError + | UnexpectedResourceError; + +/** + * 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 index fd3cfcb..6a95443 100644 --- a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts +++ b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts @@ -5,4 +5,5 @@ import type { ResourceSuccess } from "../../../requester/results/success/Success */ export interface SubscribeToNotificationSuccess extends ResourceSuccess { type: "subscribeToNotificationSuccess"; + subscriptionId: string; } diff --git a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts index 5aa97e7..0b4c90c 100644 --- a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts +++ b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts @@ -5,4 +5,5 @@ import type { ResourceSuccess } from "../../../requester/results/success/Success */ export interface UnsubscribeToNotificationSuccess extends ResourceSuccess { type: "unsubscribeFromNotificationSuccess"; + subscriptionId: string; } diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 22b60e7..3442aef 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -175,7 +175,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 }); }); @@ -2037,6 +2037,7 @@ describe("Integration", () => { const subscriptionResult = await resource.subscribeToNotifications(); expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + if (subscriptionResult.type !== "subscribeToNotificationSuccess") return; expect(resource.isSubscribedToNotifications()).toBe(true); @@ -2060,7 +2061,9 @@ describe("Integration", () => { // Notification is not propogated after unsubscribe spidermanCallback.mockClear(); - const unsubscribeResponse = await resource.unsubscribeFromNotifications(); + const unsubscribeResponse = await resource.unsubscribeFromNotifications( + subscriptionResult.subscriptionId, + ); expect(unsubscribeResponse.type).toBe( "unsubscribeFromNotificationSuccess", ); @@ -2116,7 +2119,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 () => { @@ -2151,7 +2154,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 () => { @@ -2190,7 +2193,7 @@ 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 () => { @@ -2222,8 +2225,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"); + const result = await resource.unsubscribeFromAllNotifications(); + expect(result.length).toBe(0); }); }); }); diff --git a/packages/solid/test/Websocket2023NotificationSubscription.test.ts b/packages/solid/test/Websocket2023NotificationSubscription.test.ts index ddf92bb..9ddf0b5 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, ); @@ -25,23 +23,19 @@ describe("Websocket2023NotificationSubscription", () => { WebSocketMock.onopen?.({} as Event); const subscriptionResult = await subPromise; - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + expect(subscriptionResult.type).toBe("success"); 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, ); From eae933b7f592bafc34833cdf89a8b27af1c80932 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Sun, 12 Jan 2025 20:37:51 -0500 Subject: [PATCH 03/11] Complete refactor for websocket --- packages/solid/src/resource/Resource.ts | 49 +++---- .../notifications/NotificationSubscription.ts | 138 +++++++++--------- .../Websocket2023NotificationSubscription.ts | 80 +++++----- .../results/NotificationErrors.ts | 17 +-- .../results/SubscribeToNotificationSuccess.ts | 9 -- .../UnsubscribeFromNotificationSuccess.ts | 9 -- packages/solid/test/Integration.test.ts | 62 +++++--- ...socket2023NotificationSubscription.test.ts | 7 +- 8 files changed, 172 insertions(+), 199 deletions(-) delete mode 100644 packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts delete mode 100644 packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index c21dc89..79c6e1c 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -38,8 +38,7 @@ import type { LeafUri } from "../util/uriTypes"; import type { NoRootContainerError } from "../requester/results/error/NoRootContainerError"; import type { NotificationSubscription, - SubscribeResult, - UnsubscribeResult, + SubscriptionCallbacks, } from "./notifications/NotificationSubscription"; import { Websocket2023NotificationSubscription } from "./notifications/Websocket2023NotificationSubscription"; import type { NotificationMessage } from "./notifications/NotificationMessage"; @@ -329,13 +328,9 @@ 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 { @@ -740,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 SubscriptionResult + * @returns SubscriptionId: A string to use to unsubscribe * * @example * ```typescript @@ -759,22 +754,20 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * ); * * // Subscribe - * const subscriptionResult = await testContainer.subscribeToNotifications( + * 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 ait for a file to be changed on the Pod. + * }); + * // ... From there you can wait for a file to be changed on the Pod. */ async subscribeToNotifications( - onNotification?: (message: NotificationMessage) => void, - onNotificationError?: (err: Error) => void, - ): Promise { - return await this.notificationSubscription.subscribeToNotifications({ - onNotification, - onNotificationError, - }); + callbacks?: SubscriptionCallbacks, + ): Promise { + return await this.notificationSubscription.subscribeToNotifications( + callbacks, + ); } /** @@ -814,15 +807,11 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * * @example * ```typescript - * const subscriptionResult = await testContainer.subscribeToNotifications(); - * await testContainer.unsubscribeFromNotifications( - * subscriptionResult.subscriptionId - * ); + * const subscriptionId = await testContainer.subscribeToNotifications(); + * await testContainer.unsubscribeFromNotifications(subscriptionId); * ``` */ - async unsubscribeFromNotifications( - subscriptionId: string, - ): Promise { + async unsubscribeFromNotifications(subscriptionId: string): Promise { return this.notificationSubscription.unsubscribeFromNotification( subscriptionId, ); @@ -836,12 +825,10 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * @example * ```typescript * const subscriptionResult = await testContainer.subscribeToNotifications(); - * await testContainer.unsubscribeFromNotifications( - * subscriptionResult.subscriptionId - * ); + * await testContainer.unsubscribeFromAllNotifications(); * ``` */ - async unsubscribeFromAllNotifications(): Promise { + 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 1c9113e..9e7d020 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -1,31 +1,9 @@ -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 { - NotificationCallbackError, - 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 SubscribeResult = - | SubscribeToNotificationSuccess - | UnsupportedNotificationError - | UnexpectedResourceError; - -export type UnsubscribeResult = - | UnsubscribeToNotificationSuccess - | UnexpectedResourceError; - -export type OpenResult = - | { type: "success" } - | UnsupportedNotificationError - | UnexpectedResourceError; - -export type CloseResult = { type: "success" } | UnexpectedResourceError; - export interface SubscriptionCallbacks { onNotification?: (message: NotificationMessage) => void; // TODO: make notification errors more specific @@ -57,74 +35,100 @@ export abstract class NotificationSubscription { return this.isOpen; } - protected onNotification(message: NotificationMessage): void { - this.parentSubscription(message); - Object.values(this.subscriptions).forEach(({ onNotification }) => { - onNotification?.(message); - }); - } - - protected onNotificationError(message: NotificationCallbackError): void { - Object.values(this.subscriptions).forEach(({ onNotificationError }) => { - onNotificationError?.(message); - }); - if (message.type === "disconnectedNotAttemptingReconnectError") { - this.isOpen = false; - } - } + /** + * =========================================================================== + * PUBLIC + * =========================================================================== + */ + /** + * @internal + * subscribeToNotifications + */ async subscribeToNotifications( - subscriptionCallbacks: SubscriptionCallbacks, - ): Promise { + subscriptionCallbacks?: SubscriptionCallbacks, + ): Promise { + const subscriptionId = v4(); + this.subscriptions[subscriptionId] = subscriptionCallbacks ?? {}; if (!this.isOpen) { - const openResult = await this.open(); - if (openResult.type !== "success") return openResult; + await this.open(); this.isOpen = true; } - const subscriptionId = v4(); - this.subscriptions[subscriptionId] = subscriptionCallbacks; - return { - isError: false, - type: "subscribeToNotificationSuccess", - uri: this.resource.uri, - subscriptionId, - }; + return subscriptionId; } - async unsubscribeFromNotification( - subscriptionId: string, - ): Promise { - delete this.subscriptions[subscriptionId]; - if (Object.keys(this.subscriptions).length === 0) { - const closeResult = await this.close(); - if (closeResult.type !== "success") return closeResult; + /** + * @internal + * unsubscribeFromNotification + */ + async unsubscribeFromNotification(subscriptionId: string): Promise { + if ( + !!this.subscriptions[subscriptionId] && + Object.keys(this.subscriptions).length === 1 + ) { + await this.close(); this.isOpen = false; } - return { - isError: false, - type: "unsubscribeFromNotificationSuccess", - uri: this.resource.uri, - subscriptionId, - }; + delete this.subscriptions[subscriptionId]; } - async unsubscribeFromAllNotifications(): Promise { - return Promise.all( + /** + * @internal + * unsubscribeFromAllNotifications + */ + async unsubscribeFromAllNotifications(): Promise { + await Promise.all( Object.keys(this.subscriptions).map((id) => this.unsubscribeFromNotification(id), ), ); } + /** + * =========================================================================== + * HELPERS + * =========================================================================== + */ + /** * @internal * Opens the subscription */ - protected abstract open(): Promise; + protected abstract open(): Promise; /** * @internal * Closes the subscription */ - protected 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.isOpen = false; + } + } } diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts index 420bd6e..80c51cd 100644 --- a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -1,5 +1,4 @@ import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; -import type { CloseResult, OpenResult } from "./NotificationSubscription"; import { NotificationSubscription } from "./NotificationSubscription"; import { SubscriptionClient } from "@solid-notifications/subscription"; import { WebSocket } from "ws"; @@ -31,7 +30,7 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript // Whether or not the socket was manually closes private isManualClose = false; // Maximum number of attempts to reconnect - private maxReconnectAttempts = 10; + private maxReconnectAttempts = 6; constructor( resource: Resource, @@ -43,18 +42,24 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript 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(); } } @@ -68,40 +73,30 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript 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 - didResolve = true; - resolve({ - type: "success", - }); - }; + 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.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.onclose = this.onClose.bind(this); - this.socket.onerror = (err) => { - if (!didResolve) { - resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); - } else { - this.onNotificationError( - new UnexpectedResourceError(this.resource.uri, err.error), - ); - } - }; - }); + this.socket.onerror = (err) => { + this.onNotificationError( + new UnexpectedResourceError(this.resource.uri, err.error), + ); + }; + return; } private onClose() { @@ -109,11 +104,9 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript // Attempt to reconnect only if the disconnection was unintentional if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; - const backoffTime = Math.min( - this.reconnectInterval * this.reconnectAttempts, - 30000, - ); // Cap backoff at 30 seconds - setTimeout(this.open, backoffTime); + setTimeout(() => { + this.open(); + }, this.reconnectInterval); this.onNotificationError( new DisconnectedAttemptingReconnectError( this.resource.uri, @@ -131,11 +124,8 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript } } - protected async close(): Promise { + protected async close(): Promise { this.socket?.terminate(); - return { - type: "success", - }; } } diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts index dcde3bb..f196c86 100644 --- a/packages/solid/src/resource/notifications/results/NotificationErrors.ts +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -1,6 +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. @@ -9,17 +15,6 @@ export class UnsupportedNotificationError extends ResourceError { readonly type = "unsupportedNotificationError" as const; } -/** - * ============================================================================= - * CALLBACK ERRORS - * ============================================================================= - */ - -export type NotificationCallbackError = - | DisconnectedAttemptingReconnectError - | DisconnectedNotAttemptingReconnectError - | UnexpectedResourceError; - /** * Indicates that the socket has disconnected and is attempting to reconnect. */ 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 6a95443..0000000 --- a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts +++ /dev/null @@ -1,9 +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"; - subscriptionId: string; -} 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 0b4c90c..0000000 --- a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts +++ /dev/null @@ -1,9 +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"; - subscriptionId: string; -} diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index 3442aef..eda134b 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -2035,9 +2035,7 @@ describe("Integration", () => { spidermanCallback, ); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); - if (subscriptionResult.type !== "subscribeToNotificationSuccess") return; + const subscriptionId = await resource.subscribeToNotifications(); expect(resource.isSubscribedToNotifications()).toBe(true); @@ -2061,12 +2059,7 @@ describe("Integration", () => { // Notification is not propogated after unsubscribe spidermanCallback.mockClear(); - const unsubscribeResponse = await resource.unsubscribeFromNotifications( - subscriptionResult.subscriptionId, - ); - expect(unsubscribeResponse.type).toBe( - "unsubscribeFromNotificationSuccess", - ); + await resource.unsubscribeFromNotifications(subscriptionId); expect(resource.isSubscribedToNotifications()).toBe(false); await authFetch(SAMPLE_DATA_URI, { method: "PATCH", @@ -2104,8 +2097,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await resource.subscribeToNotifications(); await authFetch(SAMPLE_DATA_URI, { method: "DELETE", @@ -2139,8 +2131,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await testContainer.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await testContainer.subscribeToNotifications(); await authFetch(SAMPLE_DATA_URI, { method: "DELETE", @@ -2174,8 +2165,7 @@ describe("Integration", () => { containerCallback, ); - const subscriptionResult = await testContainer.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + await testContainer.subscribeToNotifications(); await authFetch(TEST_CONTAINER_URI, { method: "POST", @@ -2198,17 +2188,34 @@ describe("Integration", () => { 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(); + }); - const subscriptionResult = await resource.subscribeToNotifications(); - expect(subscriptionResult.type).toBe("unexpectedResourceError"); + 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(); + + 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( @@ -2216,8 +2223,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(); @@ -2225,8 +2243,8 @@ describe("Integration", () => { it("causes no problems when unsubscribing when not subscribed", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); - const result = await resource.unsubscribeFromAllNotifications(); - expect(result.length).toBe(0); + 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 9ddf0b5..1f97b0d 100644 --- a/packages/solid/test/Websocket2023NotificationSubscription.test.ts +++ b/packages/solid/test/Websocket2023NotificationSubscription.test.ts @@ -22,8 +22,7 @@ describe("Websocket2023NotificationSubscription", () => { } as unknown as NotificationChannel); WebSocketMock.onopen?.({} as Event); - const subscriptionResult = await subPromise; - expect(subscriptionResult.type).toBe("success"); + await subPromise; WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent); }); @@ -44,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; }); }); From b42ed2dbf3fb17632631f83d6393b9c3ea961311 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Sun, 12 Jan 2025 21:38:40 -0500 Subject: [PATCH 04/11] Updated solid-react to follow the new subscription in solid --- packages/solid-react/src/useResource.ts | 12 ++++++--- .../solid-react/test/Integration.test.tsx | 27 ++++++++++++------- .../notifications/NotificationSubscription.ts | 18 ++++++++++--- 3 files changed, 40 insertions(+), 17 deletions(-) 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/test/Integration.test.tsx b/packages/solid-react/test/Integration.test.tsx index d4d47dd..4497be4 100644 --- a/packages/solid-react/test/Integration.test.tsx +++ b/packages/solid-react/test/Integration.test.tsx @@ -372,7 +372,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 +392,16 @@ describe("Integration Tests", () => { return (
+

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

    {post.publisher.map((publisher) => { return
  • {publisher["@id"]}
  • ; })}
+
); }; @@ -404,15 +411,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 +432,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(); }); }); diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index 9e7d020..ce77b89 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -19,7 +19,7 @@ export abstract class NotificationSubscription { protected parentSubscription: (message: NotificationMessage) => void; protected context: SolidLdoDatasetContext; protected subscriptions: Record = {}; - protected isOpen: boolean = false; + private isOpen: boolean = false; constructor( resource: Resource, @@ -52,7 +52,7 @@ export abstract class NotificationSubscription { this.subscriptions[subscriptionId] = subscriptionCallbacks ?? {}; if (!this.isOpen) { await this.open(); - this.isOpen = true; + this.setIsOpen(true); } return subscriptionId; } @@ -67,7 +67,7 @@ export abstract class NotificationSubscription { Object.keys(this.subscriptions).length === 1 ) { await this.close(); - this.isOpen = false; + this.setIsOpen(false); } delete this.subscriptions[subscriptionId]; } @@ -128,7 +128,17 @@ export abstract class NotificationSubscription { onNotificationError?.(message); }); if (message.type === "disconnectedNotAttemptingReconnectError") { - this.isOpen = false; + this.setIsOpen(false); } } + + /** + * @internal + * setIsOpen + */ + protected setIsOpen(status: boolean) { + const shouldUpdate = status === this.isOpen; + this.isOpen = status; + if (shouldUpdate) this.resource.emit("update"); + } } From 0f35c7c7a8acdc00ddabb5f3bd360b2326337c55 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 09:57:51 -0500 Subject: [PATCH 05/11] Added useSubscribeToNotifications --- .../solid-react/src/useSubscribeToResource.ts | 52 ++++++++ .../solid-react/test/Integration.test.tsx | 116 ++++++++++++++++++ .../src/react/useTypeIndex.ts | 20 +-- .../src/react/util/useSubscribeToUris.ts | 35 ++++++ .../notifications/NotificationSubscription.ts | 2 +- 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 packages/solid-react/src/useSubscribeToResource.ts create mode 100644 packages/solid-type-index/src/react/util/useSubscribeToUris.ts 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 4497be4..b6e5641 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); @@ -519,4 +520,119 @@ 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-type-index/src/react/useTypeIndex.ts b/packages/solid-type-index/src/react/useTypeIndex.ts index 122c069..87ce97a 100644 --- a/packages/solid-type-index/src/react/useTypeIndex.ts +++ b/packages/solid-type-index/src/react/useTypeIndex.ts @@ -1,12 +1,11 @@ import type { LeafUri } from "@ldo/solid"; import { useTypeIndexProfile } from "./useTypeIndexProfile"; -import { useEffect, useMemo } from "react"; -import { useLdo } from "@ldo/solid-react"; +import { useMemo } from "react"; +import { useSubscribeToUris } from "./util/useSubscribeToUris"; export function useTypeIndex(classUri: string): Promise { - const { dataset } = useLdo(); - const profile = useTypeIndexProfile(); + const typeIndexUris: string[] = useMemo(() => { const uris: string[] = []; profile?.privateTypeIndex?.forEach((indexNode) => { @@ -15,17 +14,10 @@ export function useTypeIndex(classUri: string): Promise { profile?.publicTypeIndex?.forEach((indexNode) => { uris.push(indexNode["@id"]); }); + return uris; }, [profile]); - useEffect(() => { - const resources = typeIndexUris.map((uri) => dataset.getResource(uri)); - resources.forEach((resource) => { - resource.readIfUnfetched(); - resource.subscribeToNotifications(); - }); + useSubscribeToUris(typeIndexUris); - return () => { - resources.forEach((resource) => resource.unsubscribeFromNotifications()); - } - }, [typeIndexUris]); + } 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/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts index ce77b89..4961075 100644 --- a/packages/solid/src/resource/notifications/NotificationSubscription.ts +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -137,7 +137,7 @@ export abstract class NotificationSubscription { * setIsOpen */ protected setIsOpen(status: boolean) { - const shouldUpdate = status === this.isOpen; + const shouldUpdate = status !== this.isOpen; this.isOpen = status; if (shouldUpdate) this.resource.emit("update"); } From 16376a4e7f12d91ef8b14fceee053e3393341ee3 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 14:38:19 -0500 Subject: [PATCH 06/11] getTypeIndex works --- package-lock.json | 82 ++++ .../solid-react/test/Integration.test.tsx | 1 - packages/solid-react/test/setUpServer.ts | 1 + .../src/.ldo/profile.context.ts | 4 +- .../src/.ldo/typeIndex.context.ts | 4 +- packages/solid-type-index/src/constants.ts | 3 + packages/solid-type-index/src/getTypeIndex.ts | 93 ++++ .../src/react/useInstanceUris.ts | 45 ++ .../src/react/useTypeIndex.ts | 23 - packages/solid-type-index/src/setTypeIndex.ts | 0 .../test/.ldo/post.context.ts | 32 -- .../solid-type-index/test/.ldo/post.schema.ts | 155 ------- .../test/.ldo/post.shapeTypes.ts | 19 - .../test/.ldo/post.typings.ts | 45 -- .../solid-type-index/test/General.test.tsx | 41 ++ .../test/Integration.test.tsx | 431 ------------------ packages/solid-type-index/test/React.tsx | 8 + packages/solid-type-index/test/setUpServer.ts | 131 +++--- .../test/test-server/solidServer.helper.ts | 2 +- 19 files changed, 349 insertions(+), 771 deletions(-) create mode 100644 packages/solid-type-index/src/constants.ts create mode 100644 packages/solid-type-index/src/getTypeIndex.ts create mode 100644 packages/solid-type-index/src/react/useInstanceUris.ts delete mode 100644 packages/solid-type-index/src/react/useTypeIndex.ts create mode 100644 packages/solid-type-index/src/setTypeIndex.ts delete mode 100644 packages/solid-type-index/test/.ldo/post.context.ts delete mode 100644 packages/solid-type-index/test/.ldo/post.schema.ts delete mode 100644 packages/solid-type-index/test/.ldo/post.shapeTypes.ts delete mode 100644 packages/solid-type-index/test/.ldo/post.typings.ts create mode 100644 packages/solid-type-index/test/General.test.tsx delete mode 100644 packages/solid-type-index/test/Integration.test.tsx create mode 100644 packages/solid-type-index/test/React.tsx diff --git a/package-lock.json b/package-lock.json index 549b9d7..7339650 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,84 @@ "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" + }, + "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", + "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/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/node_modules/ts-jest": { "version": "27.1.5", "dev": true, diff --git a/packages/solid-react/test/Integration.test.tsx b/packages/solid-react/test/Integration.test.tsx index b6e5641..fbeb8cd 100644 --- a/packages/solid-react/test/Integration.test.tsx +++ b/packages/solid-react/test/Integration.test.tsx @@ -533,7 +533,6 @@ describe("Integration Tests", () => { 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`); 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/src/.ldo/profile.context.ts b/packages/solid-type-index/src/.ldo/profile.context.ts index 534da7b..e1e31a2 100644 --- a/packages/solid-type-index/src/.ldo/profile.context.ts +++ b/packages/solid-type-index/src/.ldo/profile.context.ts @@ -1,11 +1,11 @@ -import { ContextDefinition } from "jsonld"; +import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; /** * ============================================================================= * profileContext: JSONLD Context for profile * ============================================================================= */ -export const profileContext: ContextDefinition = { +export const profileContext: LdoJsonldContext = { privateTypeIndex: { "@id": "http://www.w3.org/ns/solid/terms#privateTypeIndex", "@type": "@id", diff --git a/packages/solid-type-index/src/.ldo/typeIndex.context.ts b/packages/solid-type-index/src/.ldo/typeIndex.context.ts index d0dbefd..71ee727 100644 --- a/packages/solid-type-index/src/.ldo/typeIndex.context.ts +++ b/packages/solid-type-index/src/.ldo/typeIndex.context.ts @@ -1,11 +1,11 @@ -import { ContextDefinition } from "jsonld"; +import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; /** * ============================================================================= * typeIndexContext: JSONLD Context for typeIndex * ============================================================================= */ -export const typeIndexContext: ContextDefinition = { +export const typeIndexContext: LdoJsonldContext = { TypeIndex: { "@id": "http://www.w3.org/ns/solid/terms#TypeIndex", "@context": { diff --git a/packages/solid-type-index/src/constants.ts b/packages/solid-type-index/src/constants.ts new file mode 100644 index 0000000..1d9e246 --- /dev/null +++ b/packages/solid-type-index/src/constants.ts @@ -0,0 +1,3 @@ +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"; diff --git a/packages/solid-type-index/src/getTypeIndex.ts b/packages/solid-type-index/src/getTypeIndex.ts new file mode 100644 index 0000000..0753eb3 --- /dev/null +++ b/packages/solid-type-index/src/getTypeIndex.ts @@ -0,0 +1,93 @@ +import type { ContainerUri, LeafUri, SolidLdoDataset } from "@ldo/solid"; +import { createSolidLdoDataset } from "@ldo/solid"; +import type { TypeRegistration } from "./.ldo/typeIndex.typings"; +import { guaranteeFetch } from "@ldo/solid/dist/util/guaranteeFetch"; +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"; + +interface GetInstanceUrisOptions { + solidLdoDataset?: SolidLdoDataset; + fetch?: typeof fetch; +} + +export async function getTypeRegistrations( + webId: string, + options?: GetInstanceUrisOptions, +): Promise { + const fetch = guaranteeFetch(options?.fetch); + const dataset = options?.solidLdoDataset ?? createSolidLdoDataset({ fetch }); + + // Get Profile + const profileResource = dataset.getResource(webId); + const readResult = await profileResource.readIfUnfetched(); + if (readResult.isError) throw readResult; + const profile = dataset + .usingType(TypeIndexProfileShapeType) + .fromSubject(webId); + + // 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 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?: GetInstanceUrisOptions, +): Promise { + const fetch = guaranteeFetch(options?.fetch); + const dataset = options?.solidLdoDataset ?? createSolidLdoDataset({ fetch }); + + 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/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/useTypeIndex.ts b/packages/solid-type-index/src/react/useTypeIndex.ts deleted file mode 100644 index 87ce97a..0000000 --- a/packages/solid-type-index/src/react/useTypeIndex.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { LeafUri } from "@ldo/solid"; -import { useTypeIndexProfile } from "./useTypeIndexProfile"; -import { useMemo } from "react"; -import { useSubscribeToUris } from "./util/useSubscribeToUris"; - -export function useTypeIndex(classUri: string): Promise { - const profile = useTypeIndexProfile(); - - const typeIndexUris: string[] = useMemo(() => { - const uris: string[] = []; - profile?.privateTypeIndex?.forEach((indexNode) => { - uris.push(indexNode["@id"]); - }); - profile?.publicTypeIndex?.forEach((indexNode) => { - uris.push(indexNode["@id"]); - }); - return uris; - }, [profile]); - - useSubscribeToUris(typeIndexUris); - - -} diff --git a/packages/solid-type-index/src/setTypeIndex.ts b/packages/solid-type-index/src/setTypeIndex.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/solid-type-index/test/.ldo/post.context.ts b/packages/solid-type-index/test/.ldo/post.context.ts deleted file mode 100644 index 5cb3a91..0000000 --- a/packages/solid-type-index/test/.ldo/post.context.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ContextDefinition } from "jsonld"; - -/** - * ============================================================================= - * postContext: JSONLD Context for post - * ============================================================================= - */ -export const postContext: ContextDefinition = { - type: { - "@id": "@type", - }, - SocialMediaPosting: "http://schema.org/SocialMediaPosting", - CreativeWork: "http://schema.org/CreativeWork", - Thing: "http://schema.org/Thing", - articleBody: { - "@id": "http://schema.org/articleBody", - "@type": "http://www.w3.org/2001/XMLSchema#string", - }, - uploadDate: { - "@id": "http://schema.org/uploadDate", - "@type": "http://www.w3.org/2001/XMLSchema#date", - }, - image: { - "@id": "http://schema.org/image", - "@type": "@id", - }, - publisher: { - "@id": "http://schema.org/publisher", - "@type": "@id", - "@container": "@set", - }, -}; diff --git a/packages/solid-type-index/test/.ldo/post.schema.ts b/packages/solid-type-index/test/.ldo/post.schema.ts deleted file mode 100644 index 39e8b63..0000000 --- a/packages/solid-type-index/test/.ldo/post.schema.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Schema } from "shexj"; - -/** - * ============================================================================= - * postSchema: ShexJ Schema for post - * ============================================================================= - */ -export const postSchema: Schema = { - type: "Schema", - shapes: [ - { - id: "https://example.com/PostSh", - 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://schema.org/SocialMediaPosting", - "http://schema.org/CreativeWork", - "http://schema.org/Thing", - ], - }, - }, - { - type: "TripleConstraint", - predicate: "http://schema.org/articleBody", - valueExpr: { - type: "NodeConstraint", - datatype: "http://www.w3.org/2001/XMLSchema#string", - }, - min: 0, - max: 1, - annotations: [ - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#label", - object: { - value: "articleBody", - }, - }, - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#comment", - object: { - value: "The actual body of the article. ", - }, - }, - ], - }, - { - type: "TripleConstraint", - predicate: "http://schema.org/uploadDate", - valueExpr: { - type: "NodeConstraint", - datatype: "http://www.w3.org/2001/XMLSchema#date", - }, - annotations: [ - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#label", - object: { - value: "uploadDate", - }, - }, - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#comment", - object: { - value: - "Date when this media object was uploaded to this site.", - }, - }, - ], - }, - { - type: "TripleConstraint", - predicate: "http://schema.org/image", - valueExpr: { - type: "NodeConstraint", - nodeKind: "iri", - }, - min: 0, - max: 1, - annotations: [ - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#label", - object: { - value: "image", - }, - }, - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#comment", - object: { - value: - "A media object that encodes this CreativeWork. This property is a synonym for encoding.", - }, - }, - ], - }, - { - type: "TripleConstraint", - predicate: "http://schema.org/publisher", - valueExpr: { - type: "NodeConstraint", - nodeKind: "iri", - }, - annotations: [ - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#label", - object: { - value: "publisher", - }, - }, - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#comment", - object: { - value: "The publisher of the creative work.", - }, - }, - ], - }, - ], - }, - annotations: [ - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#label", - object: { - value: "SocialMediaPost", - }, - }, - { - type: "Annotation", - predicate: "http://www.w3.org/2000/01/rdf-schema#comment", - object: { - value: - "A post to a social media platform, including blog posts, tweets, Facebook posts, etc.", - }, - }, - ], - }, - }, - ], -}; diff --git a/packages/solid-type-index/test/.ldo/post.shapeTypes.ts b/packages/solid-type-index/test/.ldo/post.shapeTypes.ts deleted file mode 100644 index 4c50683..0000000 --- a/packages/solid-type-index/test/.ldo/post.shapeTypes.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ShapeType } from "@ldo/ldo"; -import { postSchema } from "./post.schema"; -import { postContext } from "./post.context"; -import { PostSh } from "./post.typings"; - -/** - * ============================================================================= - * LDO ShapeTypes post - * ============================================================================= - */ - -/** - * PostSh ShapeType - */ -export const PostShShapeType: ShapeType = { - schema: postSchema, - shape: "https://example.com/PostSh", - context: postContext, -}; diff --git a/packages/solid-type-index/test/.ldo/post.typings.ts b/packages/solid-type-index/test/.ldo/post.typings.ts deleted file mode 100644 index 1425a9a..0000000 --- a/packages/solid-type-index/test/.ldo/post.typings.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ContextDefinition } from "jsonld"; - -/** - * ============================================================================= - * Typescript Typings for post - * ============================================================================= - */ - -/** - * PostSh Type - */ -export interface PostSh { - "@id"?: string; - "@context"?: ContextDefinition; - type: - | { - "@id": "SocialMediaPosting"; - } - | { - "@id": "CreativeWork"; - } - | { - "@id": "Thing"; - }; - /** - * The actual body of the article. - */ - articleBody?: string; - /** - * Date when this media object was uploaded to this site. - */ - uploadDate: string; - /** - * A media object that encodes this CreativeWork. This property is a synonym for encoding. - */ - image?: { - "@id": string; - }; - /** - * The publisher of the creative work. - */ - publisher: { - "@id": string; - }[]; -} 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..0b16062 --- /dev/null +++ b/packages/solid-type-index/test/General.test.tsx @@ -0,0 +1,41 @@ +import { createSolidLdoDataset } from "@ldo/solid"; +import { + MY_BOOKMARKS_1_URI, + MY_BOOKMARKS_2_URI, + setUpServer, + WEB_ID, +} from "./setUpServer"; +import { getInstanceUris, getTypeRegistrations } from "../src/getTypeIndex"; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +describe("General Tests", () => { + setUpServer(); + + it("gets the current typeindex", async () => { + const solidLdoDataset = createSolidLdoDataset(); + const typeRegistrations = await getTypeRegistrations(WEB_ID, { + solidLdoDataset, + }); + const addressBookUris = await getInstanceUris( + "http://www.w3.org/2006/vcard/ns#AddressBook", + typeRegistrations, + { solidLdoDataset }, + ); + expect(addressBookUris).toEqual( + expect.arrayContaining([ + "https://example.com/myPrivateAddressBook.ttl", + "https://example.com/myPublicAddressBook.ttl", + ]), + ); + const bookmarkUris = await getInstanceUris( + "http://www.w3.org/2002/01/bookmark#Bookmark", + typeRegistrations, + { solidLdoDataset }, + ); + expect(bookmarkUris).toEqual( + expect.arrayContaining([MY_BOOKMARKS_1_URI, MY_BOOKMARKS_2_URI]), + ); + }); +}); diff --git a/packages/solid-type-index/test/Integration.test.tsx b/packages/solid-type-index/test/Integration.test.tsx deleted file mode 100644 index 8c8ae0b..0000000 --- a/packages/solid-type-index/test/Integration.test.tsx +++ /dev/null @@ -1,431 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import type { FunctionComponent } from "react"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import { - SAMPLE_BINARY_URI, - SAMPLE_DATA_URI, - SERVER_DOMAIN, - setUpServer, -} from "./setUpServer"; -import { UnauthenticatedSolidLdoProvider } from "../src/UnauthenticatedSolidLdoProvider"; -import { useResource } from "../src/useResource"; -import { useRootContainerFor } from "../src/useRootContainer"; -import { useLdo } from "../src/SolidLdoProvider"; -import { PostShShapeType } from "./.ldo/post.shapeTypes"; -import type { PostSh } from "./.ldo/post.typings"; -import { useSubject } from "../src/useSubject"; - -// Use an increased timeout, since the CSS server takes too much setup time. -jest.setTimeout(40_000); - -describe("Integration Tests", () => { - setUpServer(); - - /** - * =========================================================================== - * useResource - * =========================================================================== - */ - describe("useResource", () => { - it("Fetches a resource and indicates it is loading while doing so", async () => { - const UseResourceTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI); - if (resource?.isLoading()) return

Loading

; - return

{resource.status.type}

; - }; - render( - - - , - ); - await screen.findByText("Loading"); - const resourceStatus = await screen.findByRole("status"); - expect(resourceStatus.innerHTML).toBe("dataReadSuccess"); - }); - - it("returns undefined when no uri is provided, then rerenders when one is", async () => { - const UseResourceUndefinedTest: FunctionComponent = () => { - const [uri, setUri] = useState(undefined); - const resource = useResource(uri, { suppressInitialRead: true }); - if (!resource) - return ( -
-

Undefined

- -
- ); - return

{resource.status.type}

; - }; - render( - - - , - ); - await screen.findByText("Undefined"); - fireEvent.click(screen.getByText("Next")); - const resourceStatus = await screen.findByRole("status"); - expect(resourceStatus.innerHTML).toBe("unfetched"); - }); - - it("Reloads the data on mount", async () => { - const ReloadTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI, { reloadOnMount: true }); - if (resource?.isLoading()) return

Loading

; - return

{resource.status.type}

; - }; - const ReloadParent: FunctionComponent = () => { - const [showComponent, setShowComponent] = useState(true); - return ( -
- - {showComponent ? :

Hidden

} -
- ); - }; - render( - - - , - ); - await screen.findByText("Loading"); - const resourceStatus1 = await screen.findByRole("status"); - expect(resourceStatus1.innerHTML).toBe("dataReadSuccess"); - fireEvent.click(screen.getByText("Show Component")); - await screen.findByText("Hidden"); - fireEvent.click(screen.getByText("Show Component")); - await screen.findByText("Loading"); - const resourceStatus2 = await screen.findByRole("status", undefined, { - timeout: 5000, - }); - expect(resourceStatus2.innerHTML).toBe("dataReadSuccess"); - }); - - it("handles swapping to a new resource", async () => { - const SwapResourceTest: FunctionComponent = () => { - const [uri, setUri] = useState(SAMPLE_DATA_URI); - const resource = useResource(uri); - if (resource?.isLoading()) return

Loading

; - return ( -
-

{resource.status.type}

- -
- ); - }; - render( - - - , - ); - await screen.findByText("Loading"); - const resourceStatus1 = await screen.findByRole("status"); - expect(resourceStatus1.innerHTML).toBe("dataReadSuccess"); - fireEvent.click(screen.getByText("Update URI")); - await screen.findByText("Loading"); - const resourceStatus2 = await screen.findByRole("status"); - expect(resourceStatus2.innerHTML).toBe("binaryReadSuccess"); - }); - }); - - /** - * =========================================================================== - * useRootContainer - * =========================================================================== - */ - describe("useRootContainer", () => { - it("gets the root container for a sub-resource", async () => { - const RootContainerTest: FunctionComponent = () => { - const rootContainer = useRootContainerFor(SAMPLE_DATA_URI, { - suppressInitialRead: true, - }); - return rootContainer ?

{rootContainer?.uri}

: <>; - }; - render( - - - , - ); - const container = await screen.findByRole("root"); - expect(container.innerHTML).toBe(SERVER_DOMAIN); - }); - - it("returns undefined when a URI is not provided", async () => { - const RootContainerTest: FunctionComponent = () => { - const rootContainer = useRootContainerFor(undefined, { - suppressInitialRead: true, - }); - return rootContainer ? ( -

{rootContainer?.uri}

- ) : ( -

Undefined

- ); - }; - render( - - - , - ); - const container = await screen.findByRole("undefined"); - expect(container.innerHTML).toBe("Undefined"); - }); - }); - - /** - * =========================================================================== - * useLdoMethods - * =========================================================================== - */ - describe("useLdoMethods", () => { - it("uses get subject to get a linked data object", async () => { - const GetSubjectTest: FunctionComponent = () => { - const [subject, setSubject] = useState(); - const { getSubject } = useLdo(); - useEffect(() => { - const someSubject = getSubject( - PostShShapeType, - "https://example.com/subject", - ); - setSubject(someSubject); - }, []); - return subject ?

{subject["@id"]}

: <>; - }; - render( - - - , - ); - const container = await screen.findByRole("subject"); - expect(container.innerHTML).toBe("https://example.com/subject"); - }); - - it("uses createData to create a new data object", async () => { - const GetSubjectTest: FunctionComponent = () => { - const [subject, setSubject] = useState(); - const { createData, getResource } = useLdo(); - useEffect(() => { - const someSubject = createData( - PostShShapeType, - "https://example.com/subject", - getResource("https://example.com/"), - ); - someSubject.articleBody = "Cool Article"; - setSubject(someSubject); - }, []); - return subject ?

{subject.articleBody}

: <>; - }; - render( - - - , - ); - const container = await screen.findByRole("subject"); - expect(container.innerHTML).toBe("Cool Article"); - }); - }); - - /** - * =========================================================================== - * useSubject - * =========================================================================== - */ - describe("useSubject", () => { - it("renders the article body from the useSubject value", async () => { - const UseSubjectTest: FunctionComponent = () => { - useResource(SAMPLE_DATA_URI); - const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); - - return

{post.articleBody}

; - }; - render( - - - , - ); - - await screen.findByText("test"); - }); - - it("renders the array value from the useSubject value", async () => { - const UseSubjectTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI); - const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); - if (resource.isLoading() || !post) return

loading

; - - return ( -
-

{post.publisher[0]["@id"]}

-
    - {post.publisher.map((publisher) => { - return
  • {publisher["@id"]}
  • ; - })} -
-
- ); - }; - render( - - - , - ); - - const single = await screen.findByRole("single"); - expect(single.innerHTML).toBe("https://example.com/Publisher1"); - 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"); - }); - - it("returns undefined in the subject URI is undefined", async () => { - const UseSubjectTest: FunctionComponent = () => { - useResource(SAMPLE_DATA_URI, { suppressInitialRead: true }); - const post = useSubject(PostShShapeType, undefined); - - return ( -

- {post === undefined ? "Undefined" : "Not Undefined"} -

- ); - }; - render( - - - , - ); - - const article = await screen.findByRole("article"); - expect(article.innerHTML).toBe("Undefined"); - }); - - it("returns nothing if a symbol key is provided", async () => { - const UseSubjectTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI); - const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); - if (resource.isLoading() || !post) return

loading

; - - return

{typeof post[Symbol.hasInstance]}

; - }; - render( - - - , - ); - - const article = await screen.findByRole("value"); - expect(article.innerHTML).toBe("undefined"); - }); - - it("returns an id if an id key is provided", async () => { - const UseSubjectTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI); - const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); - if (resource.isLoading() || !post) return

loading

; - - return

{post["@id"]}

; - }; - render( - - - , - ); - - const article = await screen.findByRole("value"); - expect(article.innerHTML).toBe(`${SAMPLE_DATA_URI}#Post1`); - }); - - it("does not set a value if a value is attempted to be set", async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - const UseSubjectTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI); - const post = useSubject(PostShShapeType, `${SAMPLE_DATA_URI}#Post1`); - if (resource.isLoading() || !post) return

loading

; - - return ( -
-

{post.articleBody}

- -
- ); - }; - render( - - - , - ); - - const article = await screen.findByRole("value"); - expect(article.innerHTML).toBe(`test`); - fireEvent.click(screen.getByText("Attempt Change")); - expect(article.innerHTML).not.toBe("bad"); - expect(warn).toHaveBeenCalledWith( - "You've attempted to set a value on a Linked Data Object from the useSubject, useMatchingSubject, or useMatchingObject hooks. These linked data objects should only be used to render data, not modify it. To modify data, use the `changeData` function.", - ); - warn.mockReset(); - }); - - it("rerenders when asked to subscribe to a resource", async () => { - const NotificationTest: FunctionComponent = () => { - const resource = useResource(SAMPLE_DATA_URI, { subscribe: true }); - 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 (resource.isLoading() || !post) return

loading

; - - return ( -
-
    - {post.publisher.map((publisher) => { - return
  • {publisher["@id"]}
  • ; - })} -
- -
- ); - }; - const { unmount } = render( - - - , - ); - - 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)); - }); - - // 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", - ); - - unmount(); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }); - }); - }); -}); diff --git a/packages/solid-type-index/test/React.tsx b/packages/solid-type-index/test/React.tsx new file mode 100644 index 0000000..42f6bd8 --- /dev/null +++ b/packages/solid-type-index/test/React.tsx @@ -0,0 +1,8 @@ +import { setUpServer } from "./setUpServer"; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +describe("React Tests", () => { + setUpServer(); +}); diff --git a/packages/solid-type-index/test/setUpServer.ts b/packages/solid-type-index/test/setUpServer.ts index 0551ccf..b5fb559 100644 --- a/packages/solid-type-index/test/setUpServer.ts +++ b/packages/solid-type-index/test/setUpServer.ts @@ -1,47 +1,49 @@ -import type { ContainerUri, LeafUri } from "@ldo/solid"; import fetch from "cross-fetch"; -export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3001/"; +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 TEST_CONTAINER_SLUG = "test_ldo/"; -export const TEST_CONTAINER_URI = - `${ROOT_CONTAINER}${TEST_CONTAINER_SLUG}` as ContainerUri; -export const SAMPLE_DATA_URI = `${TEST_CONTAINER_URI}sample.ttl` as LeafUri; -export const SAMPLE2_DATA_SLUG = "sample2.ttl"; -export const SAMPLE2_DATA_URI = - `${TEST_CONTAINER_URI}${SAMPLE2_DATA_SLUG}` as LeafUri; -export const SAMPLE_BINARY_URI = `${TEST_CONTAINER_URI}sample.txt` as LeafUri; -export const SAMPLE2_BINARY_SLUG = `sample2.txt`; -export const SAMPLE2_BINARY_URI = - `${TEST_CONTAINER_URI}${SAMPLE2_BINARY_SLUG}` as LeafUri; -export const SAMPLE_CONTAINER_URI = - `${TEST_CONTAINER_URI}sample_container/` as ContainerUri; -export const EXAMPLE_POST_TTL = `@prefix schema: . +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`; -<#Post1> - a schema:CreativeWork, schema:Thing, schema:SocialMediaPosting ; - schema:image ; - schema:articleBody "test" ; - schema:publisher , .`; -export const TEST_CONTAINER_TTL = `@prefix dc: . -@prefix ldp: . -@prefix posix: . -@prefix xsd: . +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: . -<> "sample.txt"; - a ldp:Container, ldp:BasicContainer, ldp:Resource; - dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. - a ldp:Resource, ; - dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. - a ldp:Resource, ; - dc:modified "2023-10-20T13:57:14.000Z"^^xsd:dateTime. -<> posix:mtime 1697810234; - ldp:contains , . - posix:mtime 1697810234; - posix:size 522. - posix:mtime 1697810234; - posix:size 10.`; +<> + 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; @@ -65,44 +67,53 @@ export function setUpServer(): SetUpServerReturn { beforeEach(async () => { s.fetchMock = jest.fn(s.authFetch); // 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: TEST_CONTAINER_SLUG, + slug: "myBookmarks/", }, }); await Promise.all([ - s.authFetch(TEST_CONTAINER_URI, { + 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: "sample.ttl" }, - body: EXAMPLE_POST_TTL, + headers: { "content-type": "text/turtle", slug: "bookmark1.ttl" }, + body: "", }), - s.authFetch(TEST_CONTAINER_URI, { + s.authFetch(MY_BOOKMARKS_CONTAINER, { method: "POST", - headers: { "content-type": "text/plain", slug: "sample.txt" }, - body: "some text.", + headers: { "content-type": "text/turtle", slug: "bookmark2.ttl" }, + body: "", }), ]); }); afterEach(async () => { await Promise.all([ - s.authFetch(SAMPLE_DATA_URI, { - method: "DELETE", - }), - s.authFetch(SAMPLE2_DATA_URI, { - method: "DELETE", - }), - s.authFetch(SAMPLE_BINARY_URI, { - method: "DELETE", - }), - s.authFetch(SAMPLE2_BINARY_URI, { - method: "DELETE", - }), - s.authFetch(SAMPLE_CONTAINER_URI, { - method: "DELETE", - }), + 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" }), ]); }); diff --git a/packages/solid-type-index/test/test-server/solidServer.helper.ts b/packages/solid-type-index/test/test-server/solidServer.helper.ts index 5dd45d8..9dd4703 100644 --- a/packages/solid-type-index/test/test-server/solidServer.helper.ts +++ b/packages/solid-type-index/test/test-server/solidServer.helper.ts @@ -26,7 +26,7 @@ export async function createApp(): Promise { ), variableBindings: {}, shorthand: { - port: 3_001, + port: 3_003, loggingLevel: "off", seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"), }, From bc9f10db0b66ecb3b8c521aad063f837e3235f26 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 16:32:07 -0500 Subject: [PATCH 07/11] Untested implementation of init type index --- package-lock.json | 16 +- packages/solid-type-index/package.json | 3 +- .../src/.ldo/typeIndex.context.ts | 3 + packages/solid-type-index/src/constants.ts | 1 + packages/solid-type-index/src/getTypeIndex.ts | 39 ++-- packages/solid-type-index/src/index.ts | 4 + .../src/react/useAddToTypeIndex.ts | 0 .../src/react/useRemoveFromTypeIndex.ts | 0 packages/solid-type-index/src/setTypeIndex.ts | 200 ++++++++++++++++++ packages/solid-type-index/src/util/Options.ts | 13 ++ .../solid-type-index/test/General.test.tsx | 24 ++- packages/solid-type-index/test/React.tsx | 8 - packages/solid-type-index/test/setUpServer.ts | 110 ++++++---- .../solid/src/SolidLdoTransactionDataset.ts | 3 + 14 files changed, 354 insertions(+), 70 deletions(-) delete mode 100644 packages/solid-type-index/src/react/useAddToTypeIndex.ts delete mode 100644 packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts create mode 100644 packages/solid-type-index/src/util/Options.ts delete mode 100644 packages/solid-type-index/test/React.tsx diff --git a/package-lock.json b/package-lock.json index 7339650..22f44a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29738,7 +29738,8 @@ "license": "MIT", "dependencies": { "@ldo/solid": "^0.0.1-alpha.28", - "@ldo/solid-react": "^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", @@ -29810,6 +29811,19 @@ "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/solid-type-index/package.json b/packages/solid-type-index/package.json index 7970b8a..b53a786 100644 --- a/packages/solid-type-index/package.json +++ b/packages/solid-type-index/package.json @@ -37,7 +37,8 @@ }, "dependencies": { "@ldo/solid": "^0.0.1-alpha.28", - "@ldo/solid-react": "^0.0.1-alpha.28" + "@ldo/solid-react": "^0.0.1-alpha.28", + "uuid": "^11.0.5" }, "files": [ "dist", diff --git a/packages/solid-type-index/src/.ldo/typeIndex.context.ts b/packages/solid-type-index/src/.ldo/typeIndex.context.ts index 71ee727..e469629 100644 --- a/packages/solid-type-index/src/.ldo/typeIndex.context.ts +++ b/packages/solid-type-index/src/.ldo/typeIndex.context.ts @@ -6,6 +6,9 @@ import { LdoJsonldContext } from "@ldo/jsonld-dataset-proxy"; * ============================================================================= */ export const typeIndexContext: LdoJsonldContext = { + type: { + "@id": "@type" + }, TypeIndex: { "@id": "http://www.w3.org/ns/solid/terms#TypeIndex", "@context": { diff --git a/packages/solid-type-index/src/constants.ts b/packages/solid-type-index/src/constants.ts index 1d9e246..4fc412b 100644 --- a/packages/solid-type-index/src/constants.ts +++ b/packages/solid-type-index/src/constants.ts @@ -1,3 +1,4 @@ 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/2006/vcard/ns#forClass"; diff --git a/packages/solid-type-index/src/getTypeIndex.ts b/packages/solid-type-index/src/getTypeIndex.ts index 0753eb3..e34981e 100644 --- a/packages/solid-type-index/src/getTypeIndex.ts +++ b/packages/solid-type-index/src/getTypeIndex.ts @@ -1,31 +1,20 @@ -import type { ContainerUri, LeafUri, SolidLdoDataset } from "@ldo/solid"; -import { createSolidLdoDataset } from "@ldo/solid"; +import type { ContainerUri, LeafUri } from "@ldo/solid"; import type { TypeRegistration } from "./.ldo/typeIndex.typings"; -import { guaranteeFetch } from "@ldo/solid/dist/util/guaranteeFetch"; 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"; - -interface GetInstanceUrisOptions { - solidLdoDataset?: SolidLdoDataset; - fetch?: typeof fetch; -} +import type { Options } from "./util/Options"; +import { guaranteeOptions } from "./util/Options"; export async function getTypeRegistrations( webId: string, - options?: GetInstanceUrisOptions, + options?: Options, ): Promise { - const fetch = guaranteeFetch(options?.fetch); - const dataset = options?.solidLdoDataset ?? createSolidLdoDataset({ fetch }); + const { dataset } = guaranteeOptions(options); // Get Profile - const profileResource = dataset.getResource(webId); - const readResult = await profileResource.readIfUnfetched(); - if (readResult.isError) throw readResult; - const profile = dataset - .usingType(TypeIndexProfileShapeType) - .fromSubject(webId); + const profile = await getProfile(webId, options); // Get Type Indexes const typeIndexUris = getTypeIndexesUrisFromProfile(profile); @@ -45,6 +34,17 @@ export async function getTypeRegistrations( .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[] { @@ -61,10 +61,9 @@ export function getTypeIndexesUrisFromProfile( export async function getInstanceUris( classUri: string, typeRegistrations: TypeRegistration[], - options?: GetInstanceUrisOptions, + options?: Options, ): Promise { - const fetch = guaranteeFetch(options?.fetch); - const dataset = options?.solidLdoDataset ?? createSolidLdoDataset({ fetch }); + const { dataset } = guaranteeOptions(options); const leafUris = new Set(); await Promise.all( diff --git a/packages/solid-type-index/src/index.ts b/packages/solid-type-index/src/index.ts index e69de29..9370593 100644 --- a/packages/solid-type-index/src/index.ts +++ 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/useAddToTypeIndex.ts b/packages/solid-type-index/src/react/useAddToTypeIndex.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts b/packages/solid-type-index/src/react/useRemoveFromTypeIndex.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/solid-type-index/src/setTypeIndex.ts b/packages/solid-type-index/src/setTypeIndex.ts index e69de29..747ac68 100644 --- a/packages/solid-type-index/src/setTypeIndex.ts +++ b/packages/solid-type-index/src/setTypeIndex.ts @@ -0,0 +1,200 @@ +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 } from "@rdfjs/data-model"; +import type { TypeRegistration } from "./.ldo/typeIndex.typings"; +import { getProfile } from "./getTypeIndex"; +import { TypeIndexProfileShapeType } from "./.ldo/profile.shapeTypes"; +import type { SolidLdoDataset } from "@ldo/solid"; +import type { Container } from "@ldo/solid"; + +/** + * ============================================================================= + * 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, true); + } + } +} + +/** + * @internal + * @param webId + * @param profileFolder + * @param dataset + */ +export async function createIndex( + webId, + profileFolder: Container, + dataset: SolidLdoDataset, + 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); + + console.log(indexResource.uri, webId); + cTypeIndex.type = [{ "@id": "ListedDocument" }, { "@id": "TypeIndex" }]; + console.log("added", transaction.getChanges().added?.toString()); + console.log("removed", transaction.getChanges().added?.toString()); + const commitResult = await transaction.commitToPod(); + if (commitResult.isError) { + commitResult.errors.forEach((err) => { + if (err.type === "invalidUriError") { + console.log(err.uri); + } + }); + 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, + ); + + // Add instances to type registration + instances.instance?.forEach((instance) => { + typeRegistration.instance?.splice( + typeRegistration.instance.findIndex((val) => val["@id"] === instance), + 1, + ); + }); + instances.instanceContainer?.forEach((instanceContainer) => { + typeRegistration.instance?.splice( + typeRegistration.instance.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 existingRegistrationUri: string | undefined = dataset + .match( + null, + namedNode(RDF_TYPE), + namedNode(TYPE_REGISTRATION), + namedNode(indexUri), + ) + .match(null, namedNode(FOR_CLASS), namedNode(classUri)) + .toArray()[0]?.subject.value; + let typeRegistration: TypeRegistration; + if (existingRegistrationUri) { + typeRegistration = dataset + .usingType(TypeRegistrationShapeType) + .write(indexUri) + .fromSubject(existingRegistrationUri); + } else { + typeRegistration = dataset.createData( + TypeRegistrationShapeType, + `${indexUri}#${v4()}`, + dataset.getResource(indexUri), + ); + 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..bc85a52 --- /dev/null +++ b/packages/solid-type-index/src/util/Options.ts @@ -0,0 +1,13 @@ +import { createSolidLdoDataset, type SolidLdoDataset } from "@ldo/solid"; +import { guaranteeFetch } from "@ldo/solid/dist/util/guaranteeFetch"; + +export interface Options { + solidLdoDataset?: SolidLdoDataset; + 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 index 0b16062..346292d 100644 --- a/packages/solid-type-index/test/General.test.tsx +++ b/packages/solid-type-index/test/General.test.tsx @@ -2,18 +2,24 @@ import { createSolidLdoDataset } from "@ldo/solid"; import { MY_BOOKMARKS_1_URI, MY_BOOKMARKS_2_URI, + setupEmptyTypeIndex, + setupFullTypeIndex, setUpServer, WEB_ID, } from "./setUpServer"; import { getInstanceUris, getTypeRegistrations } from "../src/getTypeIndex"; +import { initTypeIndex } from "../src/setTypeIndex"; +import { TypeIndexProfileShapeType } from "../src/.ldo/profile.shapeTypes"; // Use an increased timeout, since the CSS server takes too much setup time. jest.setTimeout(40_000); describe("General Tests", () => { - setUpServer(); + const s = setUpServer(); it("gets the current typeindex", async () => { + await setupFullTypeIndex(s); + const solidLdoDataset = createSolidLdoDataset(); const typeRegistrations = await getTypeRegistrations(WEB_ID, { solidLdoDataset, @@ -38,4 +44,20 @@ describe("General Tests", () => { 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); + console.log(solidLdoDataset.toString()); + + expect(profile.privateTypeIndex?.["@id"]).toBeDefined(); + expect(profile.publicTypeIndex?.["@id"]).toBeDefined(); + }); }); diff --git a/packages/solid-type-index/test/React.tsx b/packages/solid-type-index/test/React.tsx deleted file mode 100644 index 42f6bd8..0000000 --- a/packages/solid-type-index/test/React.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { setUpServer } from "./setUpServer"; - -// Use an increased timeout, since the CSS server takes too much setup time. -jest.setTimeout(40_000); - -describe("React Tests", () => { - setUpServer(); -}); diff --git a/packages/solid-type-index/test/setUpServer.ts b/packages/solid-type-index/test/setUpServer.ts index b5fb559..04fc479 100644 --- a/packages/solid-type-index/test/setUpServer.ts +++ b/packages/solid-type-index/test/setUpServer.ts @@ -53,6 +53,77 @@ export interface SetUpServerReturn { >; } +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 @@ -66,45 +137,6 @@ export function setUpServer(): SetUpServerReturn { beforeEach(async () => { s.fetchMock = jest.fn(s.authFetch); - // 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: "", - }), - ]); }); afterEach(async () => { diff --git a/packages/solid/src/SolidLdoTransactionDataset.ts b/packages/solid/src/SolidLdoTransactionDataset.ts index cbacbb3..65bcaf9 100644 --- a/packages/solid/src/SolidLdoTransactionDataset.ts +++ b/packages/solid/src/SolidLdoTransactionDataset.ts @@ -117,6 +117,8 @@ export class SolidLdoTransactionDataset const changes = this.getChanges(); const changesByGraph = splitChangesByGraph(changes); + console.log(changesByGraph); + // Iterate through all changes by graph in const results: [ GraphNode, @@ -138,6 +140,7 @@ export class SolidLdoTransactionDataset ]; } if (isContainerUri(graph.value)) { + console.log(datasetChanges.removed?.toString()); return [ graph, datasetChanges, From 483cd69db9a8051555133f4e4176df9ba4c33d83 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 16:51:08 -0500 Subject: [PATCH 08/11] Committing to a resource no longer errors, its simply ignored --- .../solid/src/SolidLdoTransactionDataset.ts | 22 +++++++++---------- .../results/success/UpdateSuccess.ts | 8 +++++++ packages/solid/test/Integration.test.ts | 17 ++++++++------ 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/solid/src/SolidLdoTransactionDataset.ts b/packages/solid/src/SolidLdoTransactionDataset.ts index 65bcaf9..f66265f 100644 --- a/packages/solid/src/SolidLdoTransactionDataset.ts +++ b/packages/solid/src/SolidLdoTransactionDataset.ts @@ -18,6 +18,7 @@ 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 +27,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"; @@ -112,7 +112,7 @@ export class SolidLdoTransactionDataset | AggregateSuccess< ResourceResult > - | AggregateError + | AggregateError > { const changes = this.getChanges(); const changesByGraph = splitChangesByGraph(changes); @@ -123,7 +123,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]) => { @@ -140,14 +140,13 @@ export class SolidLdoTransactionDataset ]; } if (isContainerUri(graph.value)) { - console.log(datasetChanges.removed?.toString()); 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); @@ -162,9 +161,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 { @@ -175,7 +172,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/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/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index eda134b..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"; @@ -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 () => { From 02b2d90f2a9f1b881f38001de337f4ab3ec643ad Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 18:30:42 -0500 Subject: [PATCH 09/11] Complete type-index --- package-lock.json | 8 ++ packages/ldo/src/index.ts | 1 + packages/solid-type-index/package.json | 1 + packages/solid-type-index/src/constants.ts | 5 +- packages/solid-type-index/src/setTypeIndex.ts | 61 ++++++------ packages/solid-type-index/src/util/Options.ts | 5 +- .../solid-type-index/test/General.test.tsx | 95 +++++++++++++++++-- packages/solid/src/SolidLdoDataset.ts | 3 +- .../solid/src/SolidLdoTransactionDataset.ts | 14 ++- packages/solid/src/index.ts | 2 + packages/solid/src/types.ts | 6 +- 11 files changed, 155 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 22f44a0..173d83b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29746,12 +29746,20 @@ "@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", 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/solid-type-index/package.json b/packages/solid-type-index/package.json index b53a786..e6a7c93 100644 --- a/packages/solid-type-index/package.json +++ b/packages/solid-type-index/package.json @@ -30,6 +30,7 @@ "@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", diff --git a/packages/solid-type-index/src/constants.ts b/packages/solid-type-index/src/constants.ts index 4fc412b..830a590 100644 --- a/packages/solid-type-index/src/constants.ts +++ b/packages/solid-type-index/src/constants.ts @@ -1,4 +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/2006/vcard/ns#forClass"; +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/setTypeIndex.ts b/packages/solid-type-index/src/setTypeIndex.ts index 747ac68..1c784dc 100644 --- a/packages/solid-type-index/src/setTypeIndex.ts +++ b/packages/solid-type-index/src/setTypeIndex.ts @@ -5,12 +5,13 @@ import { } from "./.ldo/typeIndex.shapeTypes"; import { FOR_CLASS, RDF_TYPE, TYPE_REGISTRATION } from "./constants"; import { guaranteeOptions, type Options } from "./util/Options"; -import { namedNode } from "@rdfjs/data-model"; +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 { SolidLdoDataset } from "@ldo/solid"; import type { Container } from "@ldo/solid"; +import type { ISolidLdoDataset } from "@ldo/solid"; +import type { NamedNode } from "@rdfjs/types"; /** * ============================================================================= @@ -32,7 +33,7 @@ export async function initTypeIndex( await createIndex(webId, profileFolder, dataset, true); } if (!profile.publicTypeIndex?.length) { - await createIndex(webId, profileFolder, dataset, true); + await createIndex(webId, profileFolder, dataset, false); } } } @@ -46,7 +47,7 @@ export async function initTypeIndex( export async function createIndex( webId, profileFolder: Container, - dataset: SolidLdoDataset, + dataset: ISolidLdoDataset, isPrivate: boolean, ) { // Create a private type index @@ -88,19 +89,9 @@ export async function createIndex( .write(indexResource.uri) .fromSubject(indexResource.uri); - console.log(indexResource.uri, webId); cTypeIndex.type = [{ "@id": "ListedDocument" }, { "@id": "TypeIndex" }]; - console.log("added", transaction.getChanges().added?.toString()); - console.log("removed", transaction.getChanges().added?.toString()); const commitResult = await transaction.commitToPod(); - if (commitResult.isError) { - commitResult.errors.forEach((err) => { - if (err.type === "invalidUriError") { - console.log(err.uri); - } - }); - throw commitResult; - } + if (commitResult.isError) throw commitResult; } /** @@ -148,6 +139,8 @@ export async function removeRegistration( options, ); + console.log(typeRegistration["@id"]); + // Add instances to type registration instances.instance?.forEach((instance) => { typeRegistration.instance?.splice( @@ -156,8 +149,9 @@ export async function removeRegistration( ); }); instances.instanceContainer?.forEach((instanceContainer) => { - typeRegistration.instance?.splice( - typeRegistration.instance.findIndex( + console.log("Splicing instanceContainers", instanceContainer); + typeRegistration.instanceContainer?.splice( + typeRegistration.instanceContainer.findIndex( (val) => val["@id"] === instanceContainer, ), 1, @@ -172,27 +166,40 @@ export function findAppropriateTypeRegistration( ) { const { dataset } = guaranteeOptions(options); // Check to see if its already in the index - const existingRegistrationUri: string | undefined = dataset + const existingRegistrationsUris: NamedNode[] = dataset .match( null, namedNode(RDF_TYPE), namedNode(TYPE_REGISTRATION), namedNode(indexUri), ) - .match(null, namedNode(FOR_CLASS), namedNode(classUri)) - .toArray()[0]?.subject.value; + .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 (existingRegistrationUri) { + if (existingRegistrationForClassUri) { typeRegistration = dataset .usingType(TypeRegistrationShapeType) .write(indexUri) - .fromSubject(existingRegistrationUri); + .fromSubject(existingRegistrationForClassUri); } else { - typeRegistration = dataset.createData( - TypeRegistrationShapeType, - `${indexUri}#${v4()}`, - dataset.getResource(indexUri), - ); + typeRegistration = dataset + .usingType(TypeRegistrationShapeType) + .write(indexUri) + .fromSubject(`${indexUri}#${v4()}`); typeRegistration.type = { "@id": "TypeRegistration" }; typeRegistration.forClass = { "@id": classUri }; } diff --git a/packages/solid-type-index/src/util/Options.ts b/packages/solid-type-index/src/util/Options.ts index bc85a52..d551bf3 100644 --- a/packages/solid-type-index/src/util/Options.ts +++ b/packages/solid-type-index/src/util/Options.ts @@ -1,8 +1,9 @@ -import { createSolidLdoDataset, type SolidLdoDataset } from "@ldo/solid"; +import { createSolidLdoDataset } from "@ldo/solid"; +import type { ISolidLdoDataset } from "@ldo/solid"; import { guaranteeFetch } from "@ldo/solid/dist/util/guaranteeFetch"; export interface Options { - solidLdoDataset?: SolidLdoDataset; + solidLdoDataset?: ISolidLdoDataset; fetch?: typeof fetch; } diff --git a/packages/solid-type-index/test/General.test.tsx b/packages/solid-type-index/test/General.test.tsx index 346292d..7cf8380 100644 --- a/packages/solid-type-index/test/General.test.tsx +++ b/packages/solid-type-index/test/General.test.tsx @@ -2,18 +2,31 @@ 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 { initTypeIndex } from "../src/setTypeIndex"; +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(); @@ -25,7 +38,7 @@ describe("General Tests", () => { solidLdoDataset, }); const addressBookUris = await getInstanceUris( - "http://www.w3.org/2006/vcard/ns#AddressBook", + ADDRESS_BOOK, typeRegistrations, { solidLdoDataset }, ); @@ -35,11 +48,9 @@ describe("General Tests", () => { "https://example.com/myPublicAddressBook.ttl", ]), ); - const bookmarkUris = await getInstanceUris( - "http://www.w3.org/2002/01/bookmark#Bookmark", - typeRegistrations, - { solidLdoDataset }, - ); + const bookmarkUris = await getInstanceUris(BOOKMARK, typeRegistrations, { + solidLdoDataset, + }); expect(bookmarkUris).toEqual( expect.arrayContaining([MY_BOOKMARKS_1_URI, MY_BOOKMARKS_2_URI]), ); @@ -55,9 +66,73 @@ describe("General Tests", () => { const profile = solidLdoDataset .usingType(TypeIndexProfileShapeType) .fromSubject(WEB_ID); - console.log(solidLdoDataset.toString()); - expect(profile.privateTypeIndex?.["@id"]).toBeDefined(); - expect(profile.publicTypeIndex?.["@id"]).toBeDefined(); + 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/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 f66265f..4a2306e 100644 --- a/packages/solid/src/SolidLdoTransactionDataset.ts +++ b/packages/solid/src/SolidLdoTransactionDataset.ts @@ -14,7 +14,6 @@ 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 { @@ -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,6 +107,15 @@ 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 @@ -117,8 +125,6 @@ export class SolidLdoTransactionDataset const changes = this.getChanges(); const changesByGraph = splitChangesByGraph(changes); - console.log(changesByGraph); - // Iterate through all changes by graph in const results: [ GraphNode, 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/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; From 3b04bdd68cf73de804c01f9cec9797f73ff6257a Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 19:56:37 -0500 Subject: [PATCH 10/11] Fixed wrong types on generated context --- packages/cli/src/templates/context.ejs | 4 ++-- .../src/context/JsonLdContextBuilder.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) 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/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]) { From c70613a7631d02ad03fb4842d7b3c8759125ab35 Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Tue, 14 Jan 2025 20:11:25 -0500 Subject: [PATCH 11/11] Added documentation in Readme --- packages/solid-type-index/README.md | 39 +++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/solid-type-index/README.md b/packages/solid-type-index/README.md index 9f6f385..f15da3e 100644 --- a/packages/solid-type-index/README.md +++ b/packages/solid-type-index/README.md @@ -1,8 +1,43 @@ # @ldo/solid-type-index -Alpha +A library to handle [type indexes](https://solid.github.io/type-indexes/index.html) with [LDO](https://ldo.js.org). -// TODO: Write readme +## 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/).