diff --git a/package-lock.json b/package-lock.json index 16ac3ea..f91e193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4604,6 +4604,18 @@ "resolved": "packages/cli", "link": true }, + "node_modules/@ldo/connected": { + "resolved": "packages/connected", + "link": true + }, + "node_modules/@ldo/connected-nextgraph": { + "resolved": "packages/connected-nextgraph", + "link": true + }, + "node_modules/@ldo/connected-solid": { + "resolved": "packages/connected-solid", + "link": true + }, "node_modules/@ldo/dataset": { "resolved": "packages/dataset", "link": true @@ -20562,35 +20574,11 @@ "ldo": "dist/index.js" } }, - "packages/cli/node_modules/@ldo/dataset": { - "version": "0.0.1-alpha.24", - "resolved": "https://registry.npmjs.org/@ldo/dataset/-/dataset-0.0.1-alpha.24.tgz", - "integrity": "sha512-Jlh6DjvLN4gOOT8SYCKQaHZB8taeHvoriQRbF3s5CtYFf8Ne20job+02cToE1kVcBCinBrjZ/qnZ777wje+Z7A==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^0.0.1-alpha.24", - "@rdfjs/dataset": "^1.1.0", - "buffer": "^6.0.3", - "readable-stream": "^4.2.0" - } - }, - "packages/cli/node_modules/@ldo/jsonld-dataset-proxy": { - "version": "0.0.1-alpha.29", - "resolved": "https://registry.npmjs.org/@ldo/jsonld-dataset-proxy/-/jsonld-dataset-proxy-0.0.1-alpha.29.tgz", - "integrity": "sha512-6r1tn/t82aJ6vjxEGyNSJTzQJgz1Z4Pu875o4EolXXJZCu7TiXBdsW6HyAuloqiIV5b6A2b+M6G0mOuObndooA==", - "license": "MIT", - "dependencies": { - "@ldo/rdf-utils": "^0.0.1-alpha.24", - "@ldo/subscribable-dataset": "^0.0.1-alpha.24", - "@rdfjs/data-model": "^1.2.0", - "@rdfjs/dataset": "^1.1.0", - "jsonld2graphobject": "^0.0.4" - } - }, "packages/cli/node_modules/@ldo/schema-converter-shex/node_modules/jsonld2graphobject": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/jsonld2graphobject/-/jsonld2graphobject-0.0.5.tgz", "integrity": "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw==", + "extraneous": true, "license": "MIT", "dependencies": { "@rdfjs/types": "^1.0.1", @@ -20599,16 +20587,6 @@ "uuid": "^8.3.2" } }, - "packages/cli/node_modules/@ldo/subscribable-dataset": { - "version": "0.0.1-alpha.24", - "resolved": "https://registry.npmjs.org/@ldo/subscribable-dataset/-/subscribable-dataset-0.0.1-alpha.24.tgz", - "integrity": "sha512-grQ0/pzdx4euBOTxMHqQqebOYBqrBbNS9Jk8sYFR4u/dEg8e6nIGz0E4beI83dHp/hT8fT18gs/gV4UxZzmphQ==", - "license": "MIT", - "dependencies": { - "@ldo/dataset": "^0.0.1-alpha.24", - "@ldo/rdf-utils": "^0.0.1-alpha.24" - } - }, "packages/cli/node_modules/@types/fs-extra": { "version": "9.0.13", "dev": true, @@ -20617,30 +20595,6 @@ "@types/node": "*" } }, - "packages/cli/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "packages/cli/node_modules/fs-extra": { "version": "10.1.0", "license": "MIT", @@ -20653,22 +20607,6 @@ "node": ">=12" } }, - "packages/cli/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "packages/cli/node_modules/ts-jest": { "version": "27.1.5", "dev": true, @@ -20736,13 +20674,194 @@ "node": ">=4.2.0" } }, - "packages/cli/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "packages/connected": { + "name": "@ldo/connected", + "version": "1.0.0-alpha.1", + "license": "MIT", + "dependencies": { + "@ldo/dataset": "^1.0.0-alpha.1", + "@ldo/ldo": "^1.0.0-alpha.1", + "@ldo/rdf-utils": "^1.0.0-alpha.1", + "@solid-notifications/subscription": "^0.1.2", + "cross-fetch": "^3.1.6", + "http-link-header": "^1.1.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@inrupt/solid-client-authn-core": "^2.2.6", + "@ldo/cli": "^1.0.0-alpha.1", + "@rdfjs/data-model": "^1.2.0", + "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", + "@solid/community-server": "^7.1.3", + "@types/jest": "^27.0.3", + "cross-env": "^7.0.3", + "dotenv": "^16.3.1", + "jest-rdf": "^1.8.0", + "ts-jest": "^27.1.2", + "ts-node": "^10.9.1", + "typed-emitter": "^2.1.0", + "typedoc": "^0.25.4", + "typedoc-plugin-markdown": "^3.17.1" + } + }, + "packages/connected-nextgraph": { + "name": "@ldo/connected-nextgraph", + "version": "1.0.0-alpha.1", + "license": "MIT", + "dependencies": { + "@ldo/dataset": "^1.0.0-alpha.1", + "@ldo/ldo": "^1.0.0-alpha.1", + "@ldo/rdf-utils": "^1.0.0-alpha.1", + "@solid-notifications/subscription": "^0.1.2", + "cross-fetch": "^3.1.6", + "http-link-header": "^1.1.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@inrupt/solid-client-authn-core": "^2.2.6", + "@ldo/cli": "^1.0.0-alpha.1", + "@rdfjs/data-model": "^1.2.0", + "@rdfjs/types": "^1.0.1", + "@solid-notifications/types": "^0.1.2", + "@solid/community-server": "^7.1.3", + "@types/jest": "^27.0.3", + "cross-env": "^7.0.3", + "dotenv": "^16.3.1", + "jest-rdf": "^1.8.0", + "ts-jest": "^27.1.2", + "ts-node": "^10.9.1", + "typed-emitter": "^2.1.0", + "typedoc": "^0.25.4", + "typedoc-plugin-markdown": "^3.17.1" + } + }, + "packages/connected-nextgraph/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": { - "uuid": "dist/bin/uuid" + "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/connected-nextgraph/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/connected-solid": { + "name": "@ldo/connected-solid", + "version": "1.0.0-alpha.1", + "license": "MIT", + "dependencies": { + "@ldo/connected": "^1.0.0-alpha.1", + "@ldo/connected-nextgraph": "^1.0.0-alpha.1" + }, + "devDependencies": {} + }, + "packages/connected/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/connected/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/dataset": { diff --git a/packages/connected-nextgraph/package.json b/packages/connected-nextgraph/package.json index 122ca09..7d44a6f 100644 --- a/packages/connected-nextgraph/package.json +++ b/packages/connected-nextgraph/package.json @@ -1,5 +1,5 @@ { - "name": "@ldo/connected-solid", + "name": "@ldo/connected-nextgraph", "version": "1.0.0-alpha.1", "description": "A plugin for @ldo/connected to work with the Solid ecosystem.", "main": "dist/index.js", diff --git a/packages/connected-nextgraph/src/index.ts b/packages/connected-nextgraph/src/index.ts index 76b6e45..caba9be 100644 --- a/packages/connected-nextgraph/src/index.ts +++ b/packages/connected-nextgraph/src/index.ts @@ -3,4 +3,4 @@ export * from "./NextGraphConnectedPlugin"; export * from "./resources/NextGraphResource"; -export * from "./util/isSolidUri"; +export * from "./util/isNextGraphUri"; diff --git a/packages/connected-nextgraph/src/resources/NextGraphResource.ts b/packages/connected-nextgraph/src/resources/NextGraphResource.ts index 0557cb0..b06f939 100644 --- a/packages/connected-nextgraph/src/resources/NextGraphResource.ts +++ b/packages/connected-nextgraph/src/resources/NextGraphResource.ts @@ -1,3 +1,74 @@ -import type { Resource } from "@ldo/connected"; +import { + Unfetched, + type ConnectedResult, + type Resource, + type ResourceResult, + type SubscriptionCallbacks, + type ResourceEventEmitter, +} from "@ldo/connected"; +import type { NextGraphUri } from "../types"; +import EventEmitter from "events"; -export class NextGraphResource implements Resource {} +export class NextGraphResource + extends (EventEmitter as new () => ResourceEventEmitter) + implements Resource +{ + public readonly uri: NextGraphUri; + public readonly type = "NextGraphResource" as const; + public status: ConnectedResult; + + constructor(uri: NextGraphUri) { + super(); + this.uri = uri; + this.status = new Unfetched(this); + } + + isLoading(): boolean { + throw new Error("Method not implemented."); + } + + isFetched(): boolean { + throw new Error("Method not implemented."); + } + + isUnfetched(): boolean { + throw new Error("Method not implemented."); + } + + isDoingInitialFetch(): boolean { + throw new Error("Method not implemented."); + } + + isPresent(): boolean { + throw new Error("Method not implemented."); + } + + isAbsent(): boolean { + throw new Error("Method not implemented."); + } + + isSubscribedToNotifications(): boolean { + throw new Error("Method not implemented."); + } + + read(): Promise> { + throw new Error("Method not implemented."); + } + + readIfAbsent(): Promise> { + throw new Error("Method not implemented."); + } + + subscribeToNotifications(callbacks?: SubscriptionCallbacks): Promise { + throw new Error("Method not implemented."); + } + + unsubscribeFromNotifications(subscriptionId: string): Promise { + throw new Error("Method not implemented."); + } + + unsubscribeFromAllNotifications(): Promise { + throw new Error("Method not implemented."); + } + +} diff --git a/packages/connected-nextgraph/src/util/isSolidUri.ts b/packages/connected-nextgraph/src/util/isNextGraphUri.ts similarity index 100% rename from packages/connected-nextgraph/src/util/isSolidUri.ts rename to packages/connected-nextgraph/src/util/isNextGraphUri.ts diff --git a/packages/connected-solid/src/test.ts b/packages/connected-solid/src/test.ts index 9ccdfcd..41550c0 100644 --- a/packages/connected-solid/src/test.ts +++ b/packages/connected-solid/src/test.ts @@ -16,4 +16,5 @@ const containerResource = dataset.getResource("https://example.com/container/"); const leafResource = dataset.getResource( "https://example.com/container/index.ttl", ); -const nextGraphResource = dataset.getResource("did:ng:cool", "solid"); + +const nextGraphResource = dataset.getResource("did:ng:cool"); diff --git a/packages/connected/src/ConnectedLdoDataset.ts b/packages/connected/src/ConnectedLdoDataset.ts index a82c1f0..3765ec5 100644 --- a/packages/connected/src/ConnectedLdoDataset.ts +++ b/packages/connected/src/ConnectedLdoDataset.ts @@ -2,13 +2,29 @@ import { LdoDataset } from "@ldo/ldo"; import type { ConnectedPlugin } from "./ConnectedPlugin"; import type { Dataset, DatasetFactory, Quad } from "@rdfjs/types"; import type { ITransactionDatasetFactory } from "@ldo/subscribable-dataset"; +import { InvalidIdentifierResource } from "./InvalidIdentifierResource"; type ReturnTypeFromArgs = T extends (arg: Arg) => infer R ? R : never; +type ResourceTypes = + | ReturnType + | InvalidIdentifierResource; +type GetResourceReturn< + Plugin extends ConnectedPlugin, + UriType extends string, +> = UriType extends Parameters[0] + ? ReturnTypeFromArgs + : ResourceTypes<[Plugin]>; export class ConnectedLdoDataset< Plugins extends ConnectedPlugin[], > extends LdoDataset { private plugins: Plugins; + /** + * @internal + * + * A mapping between a resource URI and a Solid resource + */ + protected resourceMap: Map>; constructor( plugins: Plugins, @@ -18,18 +34,55 @@ export class ConnectedLdoDataset< ) { super(datasetFactory, transactionDatasetFactory, initialDataset); this.plugins = plugins; + this.resourceMap = new Map(); } + /** + * Retireves a representation of a Resource at the given URI. This resource + * represents the current state of the resource: whether it is currently + * fetched or in the process of fetching as well as some information about it. + * + * @param uri - the URI of the resource + * @param pluginName - optionally, force this function to choose a specific + * plugin to use rather than perform content negotiation. + * + * @returns a Resource + * + * @example + * ```typescript + * const profileDocument = solidLdoDataset + * .getResource("https://example.com/profile"); + * ``` + */ getResource< Name extends Plugins[number]["name"], Plugin extends Extract, UriType extends string, - >( - _uri: UriType, - _pluginName?: Name, - ): UriType extends Parameters[0] - ? ReturnTypeFromArgs - : ReturnType { - throw new Error("Not Implemented"); + >(uri: UriType, pluginName?: Name): GetResourceReturn { + // Check for which plugins this uri is valid + const validPlugins = this.plugins + .filter((plugin) => plugin.isUriValid(uri)) + .filter((plugin) => (pluginName ? pluginName === plugin.name : true)); + if (validPlugins.length === 0) { + return new InvalidIdentifierResource(uri) as GetResourceReturn< + Plugin, + UriType + >; + } else if (validPlugins.length > 1) { + // TODO: LDO is currently not architected to have an ID valid in multiple + // protocols. This will need to be refactored if this is ever the case. + throw new Error( + "LDO Connect does not currently support two plugins with overlappng uris", + ); + } + const plugin = validPlugins[0]; + const normalizedUri = plugin.normalizeUri?.(uri) ?? uri; + + let resource = this.resourceMap.get(normalizedUri); + if (!resource) { + resource = plugin.getResource(uri) as ResourceTypes; + this.resourceMap.set(normalizedUri, resource); + } + return resource as GetResourceReturn; } } diff --git a/packages/connected/src/ConnectedPlugin.ts b/packages/connected/src/ConnectedPlugin.ts index 3ffda7b..1c371a8 100644 --- a/packages/connected/src/ConnectedPlugin.ts +++ b/packages/connected/src/ConnectedPlugin.ts @@ -3,4 +3,7 @@ import type { Resource } from "./Resource"; export interface ConnectedPlugin { name: string; getResource(uri: string): Resource; + createResource(): Promise; + isUriValid(uri: string): boolean; + normalizeUri?: (uri: string) => string; } diff --git a/packages/connected/src/InvalidIdentifierResource.ts b/packages/connected/src/InvalidIdentifierResource.ts new file mode 100644 index 0000000..546abbc --- /dev/null +++ b/packages/connected/src/InvalidIdentifierResource.ts @@ -0,0 +1,64 @@ +import EventEmitter from "events"; +import type { Resource, ResourceEventEmitter } from "./Resource"; +import { InvalidUriError } from "./results/error/InvalidUriError"; +import type { SubscriptionCallbacks } from "./notifications/NotificationSubscription"; + +export class InvalidIdentifierResource + extends (EventEmitter as new () => ResourceEventEmitter) + implements Resource +{ + public readonly uri: string; + public readonly type = "InvalidIdentifierResouce" as const; + public status: InvalidUriError; + + constructor(uri: string) { + super(); + this.uri = uri; + this.status = new InvalidUriError(this); + } + + isLoading(): boolean { + return false; + } + isFetched(): boolean { + return false; + } + isUnfetched(): boolean { + return true; + } + isDoingInitialFetch(): boolean { + return false; + } + isPresent(): boolean { + return false; + } + isAbsent(): boolean { + return true; + } + isSubscribedToNotifications(): boolean { + return false; + } + async read(): Promise> { + return this.status; + } + async readIfAbsent(): Promise> { + return this.status; + } + async createAndOverwrite(): Promise> { + return this.status; + } + async createIfAbsent(): Promise> { + return this.status; + } + async subscribeToNotifications( + _callbacks?: SubscriptionCallbacks, + ): Promise { + throw new Error("Cannot subscribe to an invalid resource."); + } + async unsubscribeFromNotifications(_subscriptionId: string): Promise { + // Do Nothing + } + async unsubscribeFromAllNotifications(): Promise { + // Do Nothing + } +} diff --git a/packages/connected/src/Resource.ts b/packages/connected/src/Resource.ts index 494c961..d3b0d46 100644 --- a/packages/connected/src/Resource.ts +++ b/packages/connected/src/Resource.ts @@ -1 +1,28 @@ -export interface Resource {} +import type TypedEmitter from "typed-emitter"; +import type { ConnectedResult } from "./results/ConnectedResult"; +import type { ResourceResult } from "./results/ResourceResult"; +import type { SubscriptionCallbacks } from "./notifications/NotificationSubscription"; + +export type ResourceEventEmitter = TypedEmitter<{ + update: () => void; + notification: () => void; +}>; + +export interface Resource + extends ResourceEventEmitter { + readonly uri: UriType; + readonly type: string; + status: ConnectedResult; + isLoading(): boolean; + isFetched(): boolean; + isUnfetched(): boolean; + isDoingInitialFetch(): boolean; + isPresent(): boolean; + isAbsent(): boolean; + isSubscribedToNotifications(): boolean; + read(): Promise>; + readIfAbsent(): Promise>; + subscribeToNotifications(callbacks?: SubscriptionCallbacks): Promise; + unsubscribeFromNotifications(subscriptionId: string): Promise; + unsubscribeFromAllNotifications(): Promise; +} diff --git a/packages/connected/src/index.ts b/packages/connected/src/index.ts index ae02b40..86a9f6c 100644 --- a/packages/connected/src/index.ts +++ b/packages/connected/src/index.ts @@ -1,5 +1,13 @@ -import { ConnectedLdoDataset } from "./ConnectedLdoDataset"; - export * from "./ConnectedLdoDataset"; export * from "./ConnectedPlugin"; export * from "./Resource"; +export * from "./InvalidIdentifierResource"; + +export * from "./notifications/NotificationMessage"; +export * from "./notifications/NotificationSubscription"; + +export * from "./results/ConnectedResult"; +export * from "./results/ResourceResult"; +export * from "./results/error/ErrorResult"; +export * from "./results/success/SuccessResult"; +export * from "./results/success/Unfetched"; diff --git a/packages/connected/src/notifications/NotificationMessage.ts b/packages/connected/src/notifications/NotificationMessage.ts new file mode 100644 index 0000000..14e5c2d --- /dev/null +++ b/packages/connected/src/notifications/NotificationMessage.ts @@ -0,0 +1,10 @@ +/** + * A message sent from the Pod as a notification + */ +export interface NotificationMessage { + "@context": string | string[]; + id: string; + type: "Update" | "Delete" | "Remove" | "Add"; + object: string; + published: string; +} diff --git a/packages/connected/src/notifications/NotificationSubscription.ts b/packages/connected/src/notifications/NotificationSubscription.ts new file mode 100644 index 0000000..4961075 --- /dev/null +++ b/packages/connected/src/notifications/NotificationSubscription.ts @@ -0,0 +1,144 @@ +import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext"; +import type { Resource } from "../Resource"; +import type { NotificationMessage } from "./NotificationMessage"; +import type { NotificationCallbackError } from "./results/NotificationErrors"; +import { v4 } from "uuid"; + +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 parentSubscription: (message: NotificationMessage) => void; + protected context: SolidLdoDatasetContext; + protected subscriptions: Record = {}; + private isOpen: boolean = false; + + constructor( + resource: Resource, + parentSubscription: (message: NotificationMessage) => void, + context: SolidLdoDatasetContext, + ) { + this.resource = resource; + this.parentSubscription = parentSubscription; + this.context = context; + } + + public isSubscribedToNotifications(): boolean { + return this.isOpen; + } + + /** + * =========================================================================== + * PUBLIC + * =========================================================================== + */ + + /** + * @internal + * subscribeToNotifications + */ + async subscribeToNotifications( + subscriptionCallbacks?: SubscriptionCallbacks, + ): Promise { + const subscriptionId = v4(); + this.subscriptions[subscriptionId] = subscriptionCallbacks ?? {}; + if (!this.isOpen) { + await this.open(); + this.setIsOpen(true); + } + return subscriptionId; + } + + /** + * @internal + * unsubscribeFromNotification + */ + async unsubscribeFromNotification(subscriptionId: string): Promise { + if ( + !!this.subscriptions[subscriptionId] && + Object.keys(this.subscriptions).length === 1 + ) { + await this.close(); + this.setIsOpen(false); + } + delete this.subscriptions[subscriptionId]; + } + + /** + * @internal + * unsubscribeFromAllNotifications + */ + async unsubscribeFromAllNotifications(): Promise { + await Promise.all( + Object.keys(this.subscriptions).map((id) => + this.unsubscribeFromNotification(id), + ), + ); + } + + /** + * =========================================================================== + * HELPERS + * =========================================================================== + */ + + /** + * @internal + * Opens the subscription + */ + protected abstract open(): Promise; + + /** + * @internal + * Closes the subscription + */ + protected abstract close(): Promise; + + /** + * =========================================================================== + * CALLBACKS + * =========================================================================== + */ + + /** + * @internal + * onNotification + */ + protected onNotification(message: NotificationMessage): void { + this.parentSubscription(message); + Object.values(this.subscriptions).forEach(({ onNotification }) => { + onNotification?.(message); + }); + } + + /** + * @internal + * onNotificationError + */ + protected onNotificationError(message: NotificationCallbackError): void { + Object.values(this.subscriptions).forEach(({ onNotificationError }) => { + onNotificationError?.(message); + }); + if (message.type === "disconnectedNotAttemptingReconnectError") { + this.setIsOpen(false); + } + } + + /** + * @internal + * setIsOpen + */ + protected setIsOpen(status: boolean) { + const shouldUpdate = status !== this.isOpen; + this.isOpen = status; + if (shouldUpdate) this.resource.emit("update"); + } +} diff --git a/packages/connected/src/notifications/results/NotificationErrors.ts b/packages/connected/src/notifications/results/NotificationErrors.ts new file mode 100644 index 0000000..f196c86 --- /dev/null +++ b/packages/connected/src/notifications/results/NotificationErrors.ts @@ -0,0 +1,30 @@ +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. + */ +export class UnsupportedNotificationError extends ResourceError { + readonly type = "unsupportedNotificationError" as const; +} + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedAttemptingReconnectError" as const; +} + +/** + * Indicates that the socket has disconnected and is attempting to reconnect. + */ +export class DisconnectedNotAttemptingReconnectError extends ResourceError { + readonly type = "disconnectedNotAttemptingReconnectError" as const; +} diff --git a/packages/connected/src/results/ConnectedResult.ts b/packages/connected/src/results/ConnectedResult.ts new file mode 100644 index 0000000..285b4cc --- /dev/null +++ b/packages/connected/src/results/ConnectedResult.ts @@ -0,0 +1,7 @@ +/** + * A type returned by all request functions + */ +export interface ConnectedResult { + readonly type: string; + readonly isError: boolean; +} diff --git a/packages/connected/src/results/ResourceResult.ts b/packages/connected/src/results/ResourceResult.ts new file mode 100644 index 0000000..89b36df --- /dev/null +++ b/packages/connected/src/results/ResourceResult.ts @@ -0,0 +1,7 @@ +import type { Resource } from "../Resource"; +import type { ResourceError } from "./error/ErrorResult"; +import type { ResourceSuccess } from "./success/SuccessResult"; + +export type ResourceResult = + | ResourceSuccess + | ResourceError; diff --git a/packages/connected/src/results/error/ErrorResult.ts b/packages/connected/src/results/error/ErrorResult.ts new file mode 100644 index 0000000..c92038d --- /dev/null +++ b/packages/connected/src/results/error/ErrorResult.ts @@ -0,0 +1,139 @@ +import type { Resource } from "../../Resource"; +import type { ConnectedResult } from "../ConnectedResult"; + +/** + * A result indicating that the request failed in some kind of way + */ +export abstract class ErrorResult extends Error implements ConnectedResult { + /** + * Indicates the specific type of error + */ + abstract readonly type: string; + + /** + * Always true + */ + readonly isError = true as const; + + /** + * @param message - a custom message for the error + */ + constructor(message?: string) { + super(message || "An unkown error was encountered."); + } +} + +/** + * An error for a specific resource + */ +export abstract class ResourceError< + ResourceType extends Resource, +> extends ErrorResult { + /** + * The URI of the resource + */ + readonly uri: ResourceType["uri"]; + + /** + * The resource that failed + */ + readonly resource: ResourceType; + + /** + * @param uri - The URI of the resource + * @param message - A custom message for the error + */ + constructor(resource: ResourceType, message?: string) { + super(message || `An unkown error for ${resource.uri}`); + this.uri = resource.uri; + this.resource = resource; + } +} + +/** + * An error that aggregates many errors + */ +export class AggregateError extends ErrorResult { + readonly type = "aggregateError" as const; + + /** + * A list of all errors returned + */ + readonly errors: ErrorType[]; + + /** + * @param errors - List of all errors returned + * @param message - A custom message for the error + */ + constructor( + errors: (ErrorType | AggregateError)[], + message?: string, + ) { + const allErrors: ErrorType[] = []; + errors.forEach((error) => { + if (error instanceof AggregateError) { + error.errors.forEach((subError) => { + allErrors.push(subError); + }); + } else { + allErrors.push(error); + } + }); + super( + message || + `Encountered multiple errors:${allErrors.reduce( + (agg, cur) => `${agg}\n${cur}`, + "", + )}`, + ); + this.errors = allErrors; + } +} + +/** + * Represents some error that isn't handled under other errors. This is usually + * returned when something threw an error that LDO did not expect. + */ +export class UnexpectedResourceError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "unexpectedResourceError" as const; + + /** + * The error that was thrown + */ + error: Error; + + /** + * @param uri - URI of the resource + * @param error - The error that was thrown + */ + constructor(resource: ResourceType, error: Error) { + super(resource, error.message); + this.error = error; + } + + /** + * @internal + * + * Creates an UnexpectedResourceError from a thrown error + * @param uri - The URI of the resource + * @param err - The thrown error + * @returns an UnexpectedResourceError + */ + static fromThrown( + resource: ResourceType, + err: unknown, + ): UnexpectedResourceError { + if (err instanceof Error) { + return new UnexpectedResourceError(resource, err); + } else if (typeof err === "string") { + return new UnexpectedResourceError(resource, new Error(err)); + } else { + return new UnexpectedResourceError( + resource, + new Error(`Error of type ${typeof err} thrown: ${err}`), + ); + } + } +} diff --git a/packages/connected/src/results/error/InvalidUriError.ts b/packages/connected/src/results/error/InvalidUriError.ts new file mode 100644 index 0000000..88d1a5c --- /dev/null +++ b/packages/connected/src/results/error/InvalidUriError.ts @@ -0,0 +1,16 @@ +import type { Resource } from "../../Resource"; +import { ResourceError } from "./ErrorResult"; + +/** + * An InvalidUriError is returned when a URI was provided that is not a valid + * URI. + */ +export class InvalidUriError< + ResourceType extends Resource, +> extends ResourceError { + readonly type = "invalidUriError" as const; + + constructor(resource: ResourceType, message?: string) { + super(resource, message || `${resource.uri} is an invalid uri.`); + } +} diff --git a/packages/connected/src/results/success/CheckRootContainerSuccess.ts b/packages/connected/src/results/success/CheckRootContainerSuccess.ts new file mode 100644 index 0000000..77a435f --- /dev/null +++ b/packages/connected/src/results/success/CheckRootContainerSuccess.ts @@ -0,0 +1,19 @@ +import type { Container } from "../../../resource/Container"; +import type { ResourceSuccess, SuccessResult } from "./SuccessResult"; + +/** + * Indicates that the request to check if a resource is the root container was + * a success. + */ +export interface CheckRootContainerSuccess extends ResourceSuccess { + type: "checkRootContainerSuccess"; + /** + * True if this resoure is the root container + */ + isRootContainer: boolean; +} + +export interface GetStorageContainerFromWebIdSuccess extends SuccessResult { + type: "getStorageContainerFromWebIdSuccess"; + storageContainers: Container[]; +} diff --git a/packages/connected/src/results/success/CreateSuccess.ts b/packages/connected/src/results/success/CreateSuccess.ts new file mode 100644 index 0000000..3d83b9f --- /dev/null +++ b/packages/connected/src/results/success/CreateSuccess.ts @@ -0,0 +1,13 @@ +import type { ResourceSuccess } from "./SuccessResult"; + +/** + * Indicates that the request to create the resource was a success. + */ +export interface CreateSuccess extends ResourceSuccess { + type: "createSuccess"; + /** + * True if there was a resource that existed before at the given URI that was + * overwritten + */ + didOverwrite: boolean; +} diff --git a/packages/connected/src/results/success/DeleteSuccess.ts b/packages/connected/src/results/success/DeleteSuccess.ts new file mode 100644 index 0000000..0345a1c --- /dev/null +++ b/packages/connected/src/results/success/DeleteSuccess.ts @@ -0,0 +1,14 @@ +import type { ResourceSuccess } from "./SuccessResult"; + +/** + * Indicates that the request to delete a resource was a success. + */ +export interface DeleteSuccess extends ResourceSuccess { + type: "deleteSuccess"; + + /** + * True if there was a resource at the provided URI that was deleted. False if + * a resource didn't exist. + */ + resourceExisted: boolean; +} diff --git a/packages/connected/src/results/success/ReadSuccess.ts b/packages/connected/src/results/success/ReadSuccess.ts new file mode 100644 index 0000000..756642a --- /dev/null +++ b/packages/connected/src/results/success/ReadSuccess.ts @@ -0,0 +1,71 @@ +import type { ResourceSuccess, SuccessResult } from "./SuccessResult"; + +/** + * Indicates that the request to read a resource was a success + */ +export interface ReadSuccess extends ResourceSuccess { + /** + * True if the resource was recalled from local memory rather than a recent + * request + */ + recalledFromMemory: boolean; +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a binary resource. + */ +export interface BinaryReadSuccess extends ReadSuccess { + type: "binaryReadSuccess"; + /** + * The raw data for the binary resource + */ + blob: Blob; + /** + * The mime type of the binary resource + */ + mimeType: string; +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a data (RDF) resource. + */ +export interface DataReadSuccess extends ReadSuccess { + type: "dataReadSuccess"; +} + +/** + * Indicates that the read request was successful and that the resource + * retrieved was a container resource. + */ +export interface ContainerReadSuccess extends ReadSuccess { + type: "containerReadSuccess"; + /** + * True if this container is a root container + */ + isRootContainer: boolean; +} + +/** + * Indicates that the read request was successful, but no resource exists at + * the provided URI. + */ +export interface AbsentReadSuccess extends ReadSuccess { + type: "absentReadSuccess"; +} + +/** + * A helper function that checks to see if a result is a ReadSuccess result + * + * @param result - the result to check + * @returns true if the result is a ReadSuccessResult result + */ +export function isReadSuccess(result: SuccessResult): result is ReadSuccess { + return ( + result.type === "binaryReadSuccess" || + result.type === "dataReadSuccess" || + result.type === "absentReadSuccess" || + result.type === "containerReadSuccess" + ); +} diff --git a/packages/connected/src/results/success/SuccessResult.ts b/packages/connected/src/results/success/SuccessResult.ts new file mode 100644 index 0000000..612e7c9 --- /dev/null +++ b/packages/connected/src/results/success/SuccessResult.ts @@ -0,0 +1,51 @@ +import type { Resource } from "../../Resource"; +import type { ConnectedResult } from "../ConnectedResult"; + +/** + * Indicates that some action taken by LDO was a success + */ +export abstract class SuccessResult implements ConnectedResult { + abstract readonly type: string; + readonly isError = false as const; +} + +/** + * Indicates that a request to a resource was aa success + */ +export abstract class ResourceSuccess< + ResourceType extends Resource, +> extends SuccessResult { + /** + * The URI of the resource + */ + uri: ResourceType["uri"]; + /** + * The resource that was successful + */ + resource: Resource; + + constructor(resource: ResourceType) { + super(); + this.uri = resource.uri; + this.resource = resource; + } +} + +/** + * A grouping of multiple successes as a result of an action + */ +export class AggregateSuccess< + SuccessType extends SuccessResult, +> extends SuccessResult { + type = "aggregateSuccess" as const; + + /** + * An array of all successesses + */ + results: SuccessType[]; + + constructor(results: SuccessType[]) { + super(); + this.results = results; + } +} diff --git a/packages/connected/src/results/success/Unfetched.ts b/packages/connected/src/results/success/Unfetched.ts new file mode 100644 index 0000000..eaf3a75 --- /dev/null +++ b/packages/connected/src/results/success/Unfetched.ts @@ -0,0 +1,11 @@ +import type { Resource } from "../../Resource"; +import { ResourceSuccess } from "./SuccessResult"; + +/** + * Indicates that a specific resource is unfetched + */ +export class Unfetched< + ResourceType extends Resource, +> extends ResourceSuccess { + readonly type = "unfetched" as const; +} diff --git a/packages/connected/src/results/success/UpdateSuccess.ts b/packages/connected/src/results/success/UpdateSuccess.ts new file mode 100644 index 0000000..5b740a0 --- /dev/null +++ b/packages/connected/src/results/success/UpdateSuccess.ts @@ -0,0 +1,24 @@ +import type { ResourceSuccess } from "./SuccessResult"; + +/** + * Indicates that an update request to a resource was successful + */ +export interface UpdateSuccess extends ResourceSuccess { + type: "updateSuccess"; +} + +/** + * Indicates that an update request to the default graph was successful. This + * data was not written to a Pod. It was only written locally. + */ +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"; +}