diff --git a/package-lock.json b/package-lock.json index 84be38b..9d198fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18782,9 +18782,9 @@ "license": "MIT" }, "node_modules/nextgraph": { - "version": "0.1.1-alpha.4", - "resolved": "https://registry.npmjs.org/nextgraph/-/nextgraph-0.1.1-alpha.4.tgz", - "integrity": "sha512-207fqu1RJ/ekN9iKJW43KOZfU7vJGeRKy/GTUwQgJzgumJ43HpMgOgruNhJQFZpA7d1Ycn3AysYQ1qfNbFBYQg==", + "version": "0.1.1-alpha.5", + "resolved": "https://registry.npmjs.org/nextgraph/-/nextgraph-0.1.1-alpha.5.tgz", + "integrity": "sha512-RtJ/Oy+PfvjwnwmTpIeIjesi9y71AuZ9MbIjZ6TKb7aZwYvhabFKhx1CTpenvTQJ077DxbNUptpOMPvmIdMeIQ==", "license": "MIT/Apache-2.0" }, "node_modules/node-addon-api": { @@ -26459,7 +26459,7 @@ "@solid-notifications/subscription": "^0.1.2", "cross-fetch": "^3.1.6", "http-link-header": "^1.1.1", - "nextgraph": "^0.1.1-alpha.4", + "nextgraph": "^0.1.1-alpha.5", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/connected-nextgraph/package.json b/packages/connected-nextgraph/package.json index 3adf117..d765b4a 100644 --- a/packages/connected-nextgraph/package.json +++ b/packages/connected-nextgraph/package.json @@ -48,7 +48,7 @@ "@solid-notifications/subscription": "^0.1.2", "cross-fetch": "^3.1.6", "http-link-header": "^1.1.1", - "nextgraph": "^0.1.1-alpha.4", + "nextgraph": "^0.1.1-alpha.5", "ws": "^8.18.0" }, "files": [ diff --git a/packages/connected-nextgraph/src/NextGraphConnectedPlugin.ts b/packages/connected-nextgraph/src/NextGraphConnectedPlugin.ts index eda21ab..729e4e5 100644 --- a/packages/connected-nextgraph/src/NextGraphConnectedPlugin.ts +++ b/packages/connected-nextgraph/src/NextGraphConnectedPlugin.ts @@ -3,9 +3,13 @@ import type { NextGraphUri } from "./types"; import { NextGraphResource } from "./resources/NextGraphResource"; import ng from "nextgraph"; import { isNextGraphUri } from "./util/isNextGraphUri"; +import { NoNextGraphStoreError } from "./results/NoNextGraphStoreError"; export interface NextGraphConnectedContext { sessionId?: string; + protectedStoreId?: string; + privateStoreId?: string; + publicStoreId?: string; } export interface NextGraphCreateResourceOptions { @@ -26,7 +30,9 @@ export interface NextGraphConnectedPlugin uri: NextGraphUri, context: ConnectedContext, ) => NextGraphResource; - createResource(context: ConnectedContext): Promise; + createResource( + context: ConnectedContext, + ): Promise; } export const nextGraphConnectedPlugin: NextGraphConnectedPlugin = { @@ -36,17 +42,26 @@ export const nextGraphConnectedPlugin: NextGraphConnectedPlugin = { uri: NextGraphUri, context: ConnectedContext, ): NextGraphResource { - // NIKO: Do I need to split into "base?" Remind me again of why I need base? return new NextGraphResource(uri, context); }, createResource: async function ( context: ConnectedContext, options?: NextGraphCreateResourceOptions, - ): Promise { + ): Promise { const storeType = options?.storeType ?? "protected"; - // TODO: determine the name of the store repo from the session id. - const storeRepo = options?.storeRepo ?? ""; + const storeRepo = + options?.storeRepo ?? + (storeType === "protected" + ? context.nextgraph.protectedStoreId + : storeType === "public" + ? context.nextgraph.publicStoreId + : storeType === "private" + ? context.nextgraph.privateStoreId + : undefined); + if (!storeRepo) { + return new NoNextGraphStoreError(); + } const nuri: NextGraphUri = await ng.doc_create( context.nextgraph.sessionId, @@ -56,7 +71,9 @@ export const nextGraphConnectedPlugin: NextGraphConnectedPlugin = { storeRepo, "store", ); - return new NextGraphResource(nuri, context); + const newResource = new NextGraphResource(nuri, context); + await newResource.read(); + return newResource; }, isUriValid: function (uri: string): uri is NextGraphUri { diff --git a/packages/connected-nextgraph/src/resources/NextGraphResource.ts b/packages/connected-nextgraph/src/resources/NextGraphResource.ts index 7b1d280..7fa99eb 100644 --- a/packages/connected-nextgraph/src/resources/NextGraphResource.ts +++ b/packages/connected-nextgraph/src/resources/NextGraphResource.ts @@ -1,9 +1,5 @@ -import type { - ConnectedContext, - ReadSuccess, - SubscriptionCallbacks, - UpdateSuccess, -} from "@ldo/connected"; +import type { ConnectedContext, SubscriptionCallbacks } from "@ldo/connected"; +import { UnexpectedResourceError, UpdateSuccess } from "@ldo/connected"; import { Unfetched, type ConnectedResult, @@ -14,8 +10,11 @@ import type { NextGraphUri } from "../types"; import EventEmitter from "events"; import type { NextGraphConnectedPlugin } from "../NextGraphConnectedPlugin"; import ng from "nextgraph"; -import type { DatasetChanges } from "@ldo/rdf-utils"; +import { changesToSparqlUpdate, type DatasetChanges } from "@ldo/rdf-utils"; import type { NextGraphNotificationMessage } from "../notifications/NextGraphNotificationMessage"; +import type { Quad } from "@rdfjs/types"; +import { namedNode, quad as createQuad } from "@rdfjs/data-model"; +import { NextGraphReadSuccess } from "../results/NextGraphReadSuccess"; export class NextGraphResource extends (EventEmitter as new () => ResourceEventEmitter) @@ -27,6 +26,10 @@ export class NextGraphResource public status: ConnectedResult; protected context: ConnectedContext; + private fetched: boolean = false; + private loading: boolean = false; + private present: boolean | undefined = undefined; + constructor( uri: NextGraphUri, context: ConnectedContext, @@ -38,45 +41,131 @@ export class NextGraphResource } isLoading(): boolean { - throw new Error("Method not implemented."); + return this.loading; } isFetched(): boolean { - throw new Error("Method not implemented."); + return this.fetched; } isUnfetched(): boolean { - throw new Error("Method not implemented."); + return !this.fetched; } isDoingInitialFetch(): boolean { - throw new Error("Method not implemented."); + return this.loading && !this.fetched; } - isPresent(): boolean { - throw new Error("Method not implemented."); + isPresent(): boolean | undefined { + return this.present; } - isAbsent(): boolean { - throw new Error("Method not implemented."); + isAbsent(): boolean | undefined { + return !this.present; } isSubscribedToNotifications(): boolean { throw new Error("Method not implemented."); } - read(): Promise> { - throw new Error("Method not implemented."); + private handleThrownError( + err: unknown, + ): UnexpectedResourceError { + const error = UnexpectedResourceError.fromThrown(this, err); + this.loading = false; + this.status = error; + this.emit("update"); + return error; } - readIfUnfetched(): Promise> { - throw new Error("Method not implemented."); + async read(): Promise< + NextGraphReadSuccess | UnexpectedResourceError + > { + try { + this.loading = true; + this.emit("update"); + + // Get the data + const sparqlResult = await ng.sparql_query( + this.context.nextgraph.sessionId, + `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${this.uri}> { ?s ?p ?o } }`, + undefined, + this.uri, + ); + // Update the dataset + const graphNode = namedNode(this.uri); + const dataset = this.context.dataset; + dataset.deleteMatches(undefined, undefined, undefined, graphNode); + dataset.addAll( + sparqlResult.map((ngQuad) => { + return createQuad( + ngQuad.subject, + ngQuad.predicate, + ngQuad.object, + graphNode, + ); + }), + ); + + // Update statuses + const result = new NextGraphReadSuccess(this, false); + this.loading = false; + this.fetched = true; + this.present = true; + this.status = result; + this.emit("update"); + return result; + } catch (err) { + if (err === "RepoNotFound") { + const result = new NextGraphReadSuccess(this, false); + this.loading = false; + this.fetched = true; + this.present = false; + this.status = result; + this.emit("update"); + return result; + } + return this.handleThrownError(err); + } + } + + async readIfUnfetched(): Promise< + NextGraphReadSuccess | UnexpectedResourceError + > { + if (this.isFetched()) { + return new NextGraphReadSuccess(this, true); + } + return this.read(); } - update( - _datasetChanges: DatasetChanges, - ): Promise> { - throw new Error("Method Not Implemented"); + async update( + datasetChanges: DatasetChanges, + ): Promise< + | UpdateSuccess + | UnexpectedResourceError + > { + this.loading = true; + this.emit("update"); + + // Optimistically apply updates + this.context.dataset.bulk(datasetChanges); + + try { + // Perform Update with remote + await ng.sparql_update( + this.context.nextgraph.sessionId, + await changesToSparqlUpdate(datasetChanges), + this.uri, + ); + return new UpdateSuccess(this); + } catch (err) { + // Revert data on error + this.context.dataset.bulk({ + added: datasetChanges.removed, + removed: datasetChanges.added, + }); + return this.handleThrownError(err); + } } protected async onNotification(_message: unknown) { @@ -89,7 +178,7 @@ export class NextGraphResource throw new Error("Method not implemented."); } - unsubscribeFromNotifications(subscriptionId: string): Promise { + unsubscribeFromNotifications(_subscriptionId: string): Promise { throw new Error("Method not implemented."); } diff --git a/packages/connected-nextgraph/src/results/NextGraphReadSuccess.ts b/packages/connected-nextgraph/src/results/NextGraphReadSuccess.ts new file mode 100644 index 0000000..4321b5b --- /dev/null +++ b/packages/connected-nextgraph/src/results/NextGraphReadSuccess.ts @@ -0,0 +1,6 @@ +import { ReadSuccess } from "@ldo/connected"; +import type { NextGraphResource } from "../resources/NextGraphResource"; + +export class NextGraphReadSuccess extends ReadSuccess { + type = "nextGraphReadSuccess" as const; +} diff --git a/packages/connected-nextgraph/src/results/NoNextGraphStoreError.ts b/packages/connected-nextgraph/src/results/NoNextGraphStoreError.ts new file mode 100644 index 0000000..2f2b471 --- /dev/null +++ b/packages/connected-nextgraph/src/results/NoNextGraphStoreError.ts @@ -0,0 +1,8 @@ +import { ErrorResult } from "@ldo/connected"; + +export class NoNextGraphStoreError extends ErrorResult { + type = "noNextGraphStore" as const; + constructor(message?: string) { + super(message ?? "No NextGraph store was provided."); + } +} diff --git a/packages/connected-nextgraph/test/integration.test.ts b/packages/connected-nextgraph/test/integration.test.ts index 8e08b00..d33b469 100644 --- a/packages/connected-nextgraph/test/integration.test.ts +++ b/packages/connected-nextgraph/test/integration.test.ts @@ -1,24 +1,41 @@ import type { ConnectedLdoDataset } from "@ldo/connected"; import ng from "nextgraph"; -import type { NextGraphConnectedPlugin } from "../src"; +import type { + NextGraphConnectedPlugin, + NextGraphResource, + NextGraphUri, +} from "../src"; import { createNextGraphLdoDataset } from "../src/createNextGraphLdoDataset"; +import { parseRdf } from "@ldo/ldo"; +import { namedNode } from "@rdfjs/data-model"; +import type { NextGraphReadSuccess } from "../src/results/NextGraphReadSuccess"; -console.log("Running tests"); +const SAMPLE_TTL = `@base . +@prefix rdf: . +@prefix rdfs: . +@prefix foaf: . +@prefix rel: . + +<#green-goblin> + rel:enemyOf <#spiderman> ; + a foaf:Person ; # in the context of the Marvel universe + foaf:name "Green Goblin" . + +<#spiderman> + rel:enemyOf <#green-goblin> ; + a foaf:Person ; + foaf:name "Spiderman", "Человек-паук"@ru .`; describe("NextGraph Plugin", () => { let nextgraphLdoDataset: ConnectedLdoDataset; beforeEach(async () => { // Generate a wallet - console.log("gen wallet"); - const [walletBinary, mnemonic] = await ng.gen_wallet_for_test( + const [wallet, mnemonic] = await ng.gen_wallet_for_test( "lL2mo9Jtgz8yWN5PSaEMMftDGXyKJNbv9atQOygmeTcA", ); - console.log("read wallet file"); - const wallet = await ng.wallet_read_file(walletBinary); - console.log("open wallet"); const openedWallet = await ng.wallet_open_with_mnemonic_words( - wallet, + wallet.wallet, mnemonic, [1, 2, 1, 2], ); @@ -26,15 +43,92 @@ describe("NextGraph Plugin", () => { const walletName = openedWallet.V0.wallet_id; const session = await ng.session_in_memory_start(walletName, userId); const sessionId = session.session_id; - console.log("after open wallet"); + const protectedStoreId = session.protected_store_id.substring(2, 46); + const publicStoreId = session.protected_store_id.substring(2, 46); + const privateStoreId = session.protected_store_id.substring(2, 46); // Get SessionId for that wallet nextgraphLdoDataset = createNextGraphLdoDataset(); - nextgraphLdoDataset.setContext("nextgraph", { sessionId }); - console.log("After ldo dataset"); + nextgraphLdoDataset.setContext("nextgraph", { + sessionId, + protectedStoreId, + publicStoreId, + privateStoreId, + }); + }); + + describe("createResource", () => { + it("creates a resource by assuming the protected store", async () => { + const resource = await nextgraphLdoDataset.createResource("nextgraph"); + expect(resource.isError).toBe(false); + const resourceAsR = resource as NextGraphResource; + expect(resourceAsR.uri).toBeDefined(); + expect(resourceAsR.isFetched()).toBe(true); + expect(resourceAsR.isPresent()).toBe(true); + }); }); - it("trivial", () => { - expect(true).toBe(true); + describe("readResource", () => { + let populatedResourceUri: NextGraphUri; + beforeEach(async () => { + const resource = (await nextgraphLdoDataset.createResource( + "nextgraph", + )) as NextGraphResource; + await resource.update({ + added: await parseRdf(SAMPLE_TTL), + }); + nextgraphLdoDataset.forgetAllResources(); + nextgraphLdoDataset.deleteMatches( + undefined, + undefined, + undefined, + undefined, + ); + populatedResourceUri = resource.uri; + }); + + it("reads a resource that exists", async () => { + expect(nextgraphLdoDataset.size).toBe(0); + const resource = nextgraphLdoDataset.getResource(populatedResourceUri); + const result = await resource.read(); + expect(result.isError).toBe(false); + expect(result.type).toBe("nextGraphReadSuccess"); + expect(resource.isAbsent()).toBe(false); + expect(resource.isPresent()).toBe(true); + expect(resource.isLoading()).toBe(false); + expect(nextgraphLdoDataset.size).toBe(7); + expect( + nextgraphLdoDataset.match( + namedNode("http://example.org/#spiderman"), + namedNode("http://www.perceive.net/schemas/relationship/enemyOf"), + namedNode("http://example.org/#green-goblin"), + namedNode(resource.uri), + ).size, + ).toBe(1); + }); + + it("reads a resource that is absent", async () => { + const nuri = + "did:ng:o:W6GCQRfQkNTLtSS_2-QhKPJPkhEtLVh-B5lzpWMjGNEA:v:h8ViqyhCYMS2I6IKwPrY6UZi4ougUm1gpM4QnxlmNMQA"; + const resource = nextgraphLdoDataset.getResource(nuri); + const readResult = await resource.read(); + expect(resource.uri).toBe(nuri); + expect(readResult.type).toBe("nextGraphReadSuccess"); + expect(nextgraphLdoDataset.size).toBe(0); + expect(resource.isLoading()).toBe(false); + expect(resource.isAbsent()).toBe(true); + }); + + it("Reads a resource from memory.", async () => { + const resource = nextgraphLdoDataset.getResource(populatedResourceUri); + await resource.read(); + const result2 = await resource.readIfUnfetched(); + expect(result2.isError).toBe(false); + const result = result2 as NextGraphReadSuccess; + expect(result.type).toBe("nextGraphReadSuccess"); + expect(result.recalledFromMemory).toBe(true); + }); }); }); + +// Errors if it doesn't exist and an update is attempted diff --git a/packages/connected/src/ConnectedLdoDataset.ts b/packages/connected/src/ConnectedLdoDataset.ts index 7200b81..e9a043f 100644 --- a/packages/connected/src/ConnectedLdoDataset.ts +++ b/packages/connected/src/ConnectedLdoDataset.ts @@ -182,7 +182,9 @@ export class ConnectedLdoDataset< pluginName: Name, createResourceOptions?: Plugin["types"]["createResourceOptions"], ): Promise> { - const validPlugin = this.plugins.find((plugin) => name === plugin.name)!; + const validPlugin = this.plugins.find( + (plugin) => pluginName === plugin.name, + )!; const newResourceResult = await validPlugin.createResource( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore I have no idea why this doesn't work @@ -272,7 +274,7 @@ export class ConnectedLdoDataset< >(pluginName: Name, context: Plugin["types"]["context"]) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - this.context[name] = context; + this.context[pluginName] = { ...this.context[pluginName], ...context }; } public startTransaction(): ConnectedLdoTransactionDataset {