diff --git a/.componentsignore b/.componentsignore index f1bc4f5ba..094a031f5 100644 --- a/.componentsignore +++ b/.componentsignore @@ -3,6 +3,7 @@ "BaseHttpError", "BasicConditions", "BasicRepresentation", + "ChangeMap", "CredentialSet", "Dict", "Error", diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a3bbb02fb..5e2673a26 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -65,6 +65,7 @@ These changes are relevant if you wrote custom modules for the server that depen * Changed type for `finalizer` attribute in `App` from `Finalizable` to `Finalizer` and updated the calling code in `App.stop()`. * Removed the now obsolete `ParallelFinalizer` util class. - Added a lock cleanup on initialize for lock implementations `RedisLocker` and `FileSystemResourceLocker`. +- `ResourceStore` functions that change a resource now return metadata for every changed resource. A new interface `SingleThreaded` has been added. This empty interface can be implemented to mark a component as not-threadsafe. When the CSS starts in multithreaded mode, it will error and halt if any SingleThreaded components are instantiated. diff --git a/documentation/markdown/resource-store.md b/documentation/markdown/resource-store.md index 3bc708d0f..fd42076f3 100644 --- a/documentation/markdown/resource-store.md +++ b/documentation/markdown/resource-store.md @@ -29,6 +29,16 @@ and all the entries in `config/storage/backend`. ## MonitoringStore This store emits the events that are necessary to emit notifications when resources change. +There are 4 different events that can be emitted: +- `this.emit('changed', identifier, AS.Create | AS.Update | AS.Delete | undefined)`: is emitted for every resource that was changed/effected by a call to the store. +- `this.emit(AS.Create, identifier)`: is emitted for every resource that was created by the call to the store. +- `this.emit(AS.Update, identifier)`: is emitted for every resource that was updated by the call to the store. +- `this.emit(AS.Delete, identifier)`: is emitted for every resource that was deleted by the call to the store. + +A `changed` event will always be emitted if a resource was changed. +If the correct metadata was set by the source `ResourceStore`, an additional field will be sent along indicating the type of change, +and an additional corresponding event will be emitted, depending on what the change is. + ## IndexRepresentationStore When doing a GET request on a container `/container/`, this container returns the contents of `/container/index.html` instead if HTML is the preferred response type. diff --git a/src/http/ldp/PostOperationHandler.ts b/src/http/ldp/PostOperationHandler.ts index 12bf648dc..aca268ca7 100644 --- a/src/http/ldp/PostOperationHandler.ts +++ b/src/http/ldp/PostOperationHandler.ts @@ -2,8 +2,10 @@ import { getLoggerFor } from '../../logging/LogUtil'; import type { ResourceStore } from '../../storage/ResourceStore'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { AS, SOLID_AS } from '../../util/Vocabularies'; import { CreatedResponseDescription } from '../output/response/CreatedResponseDescription'; import type { ResponseDescription } from '../output/response/ResponseDescription'; +import { createResourceIdentifier } from '../representation/ResourceIdentifier'; import type { OperationHandlerInput } from './OperationHandler'; import { OperationHandler } from './OperationHandler'; @@ -35,7 +37,10 @@ export class PostOperationHandler extends OperationHandler { this.logger.warn('POST requests require the Content-Type header to be set'); throw new BadRequestHttpError('POST requests require the Content-Type header to be set'); } - const identifier = await this.store.addResource(operation.target, operation.body, operation.conditions); - return new CreatedResponseDescription(identifier); + const result = await this.store.addResource(operation.target, operation.body, operation.conditions); + const createdIdentifier = Object.entries(result).find( + ([ , value ]): boolean => value.get(SOLID_AS.terms.Activity)?.value === AS.Create, + )![0]; + return new CreatedResponseDescription(createResourceIdentifier(createdIdentifier)); } } diff --git a/src/http/representation/ResourceIdentifier.ts b/src/http/representation/ResourceIdentifier.ts index f8f9c2923..000b7cf9a 100644 --- a/src/http/representation/ResourceIdentifier.ts +++ b/src/http/representation/ResourceIdentifier.ts @@ -9,8 +9,15 @@ export interface ResourceIdentifier { } /** - * Determines whether the object is a `ResourceIdentifier`. + * Determines whether the object is a {@link ResourceIdentifier}. */ export function isResourceIdentifier(object: any): object is ResourceIdentifier { return object && (typeof object.path === 'string'); } + +/** + * Factory function creating a {@link ResourceIdentifier} for convenience. + */ +export function createResourceIdentifier(resourcePath: string): ResourceIdentifier { + return { path: resourcePath }; +} diff --git a/src/storage/BaseResourceStore.ts b/src/storage/BaseResourceStore.ts index 16c208e8e..c1df9be6e 100644 --- a/src/storage/BaseResourceStore.ts +++ b/src/storage/BaseResourceStore.ts @@ -4,7 +4,7 @@ import type { RepresentationPreferences } from '../http/representation/Represent import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Base implementation of ResourceStore for implementers of custom stores. @@ -21,22 +21,22 @@ export class BaseResourceStore implements ResourceStore { } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new NotImplementedHttpError(); } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new NotImplementedHttpError(); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new NotImplementedHttpError(); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new NotImplementedHttpError(); } } diff --git a/src/storage/DataAccessorBasedStore.ts b/src/storage/DataAccessorBasedStore.ts index 1ae29cc2d..45761c8b6 100644 --- a/src/storage/DataAccessorBasedStore.ts +++ b/src/storage/DataAccessorBasedStore.ts @@ -6,7 +6,7 @@ import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy'; import { BasicRepresentation } from '../http/representation/BasicRepresentation'; import type { Patch } from '../http/representation/Patch'; import type { Representation } from '../http/representation/Representation'; -import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; +import { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import { INTERNAL_QUADS } from '../util/ContentTypes'; @@ -39,10 +39,12 @@ import { SOLID_META, PREFERRED_PREFIX_TERM, CONTENT_TYPE_TERM, + SOLID_AS, + AS, } from '../util/Vocabularies'; import type { DataAccessor } from './accessors/DataAccessor'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * ResourceStore which uses a DataAccessor for backend access. @@ -138,7 +140,7 @@ export class DataAccessorBasedStore implements ResourceStore { } public async addResource(container: ResourceIdentifier, representation: Representation, conditions?: Conditions): - Promise { + Promise { this.validateIdentifier(container); const parentMetadata = await this.getSafeNormalizedMetadata(container); @@ -174,13 +176,11 @@ export class DataAccessorBasedStore implements ResourceStore { } // Write the data. New containers should never be made for a POST request. - await this.writeData(newID, representation, isContainer, false, false); - - return newID; + return this.writeData(newID, representation, isContainer, false, false); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { this.validateIdentifier(identifier); // Check if the resource already exists @@ -216,7 +216,7 @@ export class DataAccessorBasedStore implements ResourceStore { } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { if (conditions) { let metadata: RepresentationMetadata | undefined; try { @@ -233,7 +233,7 @@ export class DataAccessorBasedStore implements ResourceStore { throw new NotImplementedHttpError('Patches are not supported by the default store.'); } - public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise { + public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise { this.validateIdentifier(identifier); const metadata = await this.accessor.getMetadata(identifier); // Solid, §5.4: "When a DELETE request targets storage’s root container or its associated ACL resource, @@ -266,22 +266,25 @@ export class DataAccessorBasedStore implements ResourceStore { // Solid, §5.4: "When a contained resource is deleted, // the server MUST also delete the associated auxiliary resources" // https://solid.github.io/specification/protocol#deleting-resources - const deleted = [ identifier ]; + const changes: ChangeMap = {}; if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) { const auxiliaries = this.auxiliaryStrategy.getAuxiliaryIdentifiers(identifier); - deleted.push(...await this.safelyDeleteAuxiliaryResources(auxiliaries)); + for (const deletedId of await this.safelyDeleteAuxiliaryResources(auxiliaries)) { + changes[deletedId.path] = this.createActivityMetadata(deletedId, AS.Delete); + } } if (!this.identifierStrategy.isRootContainer(identifier)) { const container = this.identifierStrategy.getParentContainer(identifier); - deleted.push(container); + changes[container.path] = this.createActivityMetadata(container, AS.Update); // Update modified date of parent await this.updateContainerModifiedDate(container); } await this.accessor.deleteResource(identifier); - return deleted; + changes[identifier.path] = this.createActivityMetadata(identifier, AS.Delete); + return changes; } /** @@ -359,7 +362,7 @@ export class DataAccessorBasedStore implements ResourceStore { * @returns Identifiers of resources that were possibly modified. */ protected async writeData(identifier: ResourceIdentifier, representation: Representation, isContainer: boolean, - createContainers: boolean, exists: boolean): Promise { + createContainers: boolean, exists: boolean): Promise { // Make sure the metadata has the correct identifier and correct type quads // Need to do this before handling container data to have the correct identifier representation.metadata.identifier = DataFactory.namedNode(identifier.path); @@ -382,18 +385,22 @@ export class DataAccessorBasedStore implements ResourceStore { // Solid, §5.3: "Servers MUST create intermediate containers and include corresponding containment triples // in container representations derived from the URI path component of PUT and PATCH requests." // https://solid.github.io/specification/protocol#writing-resources - const modified = []; + let changes: ChangeMap = {}; if (!this.identifierStrategy.isRootContainer(identifier) && !exists) { - const container = this.identifierStrategy.getParentContainer(identifier); + const parent = this.identifierStrategy.getParentContainer(identifier); if (!createContainers) { - modified.push(container); + changes[parent.path] = this.createActivityMetadata(parent, AS.Update); } else { - const created = await this.createRecursiveContainers(container); - modified.push(...created.length === 0 ? [ container ] : created); + const createdContainers = await this.createRecursiveContainers(parent); + changes = { ...changes, ...createdContainers }; + + if (Object.keys(createdContainers).length === 0) { + changes[parent.path] = this.createActivityMetadata(parent, AS.Update); + } } // Parent container is also modified - await this.updateContainerModifiedDate(container); + await this.updateContainerModifiedDate(parent); } // Remove all generated metadata to prevent it from being stored permanently @@ -403,7 +410,8 @@ export class DataAccessorBasedStore implements ResourceStore { this.accessor.writeContainer(identifier, representation.metadata) : this.accessor.writeDocument(identifier, representation.data, representation.metadata)); - return [ ...modified, identifier ]; + changes[identifier.path] = this.createActivityMetadata(identifier, exists ? AS.Update : AS.Create); + return changes; } /** @@ -597,7 +605,7 @@ export class DataAccessorBasedStore implements ResourceStore { * Will throw errors if the identifier of the last existing "container" corresponds to an existing document. * @param container - Identifier of the container which will need to exist. */ - protected async createRecursiveContainers(container: ResourceIdentifier): Promise { + protected async createRecursiveContainers(container: ResourceIdentifier): Promise { // Verify whether the container already exists try { const metadata = await this.getNormalizedMetadata(container); @@ -609,7 +617,7 @@ export class DataAccessorBasedStore implements ResourceStore { if (!isContainerPath(metadata.identifier.value)) { throw new ForbiddenHttpError(`Creating container ${container.path} conflicts with an existing resource.`); } - return []; + return {}; } catch (error: unknown) { if (!NotFoundHttpError.isInstance(error)) { throw error; @@ -618,9 +626,14 @@ export class DataAccessorBasedStore implements ResourceStore { // Create the container, starting with its parent const ancestors = this.identifierStrategy.isRootContainer(container) ? - [] : + {} : await this.createRecursiveContainers(this.identifierStrategy.getParentContainer(container)); - await this.writeData(container, new BasicRepresentation([], container), true, false, false); - return [ ...ancestors, container ]; + const changes = await this.writeData(container, new BasicRepresentation([], container), true, false, false); + + return { ...changes, ...ancestors }; + } + + private createActivityMetadata(id: ResourceIdentifier, activity: string): RepresentationMetadata { + return new RepresentationMetadata(id, { [SOLID_AS.terms.Activity.value]: activity }); } } diff --git a/src/storage/LockingResourceStore.ts b/src/storage/LockingResourceStore.ts index 9e76c7c5d..2107460b2 100644 --- a/src/storage/LockingResourceStore.ts +++ b/src/storage/LockingResourceStore.ts @@ -10,7 +10,7 @@ import type { ExpiringReadWriteLocker } from '../util/locking/ExpiringReadWriteL import { endOfStream } from '../util/StreamUtil'; import type { AtomicResourceStore } from './AtomicResourceStore'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Store that for every call acquires a lock before executing it on the requested resource, @@ -46,27 +46,27 @@ export class LockingResourceStore implements AtomicResourceStore { } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.locks.withWriteLock(this.getLockIdentifier(container), - async(): Promise => this.source.addResource(container, representation, conditions)); + async(): Promise => this.source.addResource(container, representation, conditions)); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.locks.withWriteLock(this.getLockIdentifier(identifier), - async(): Promise => this.source.setRepresentation(identifier, representation, conditions)); + async(): Promise => this.source.setRepresentation(identifier, representation, conditions)); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.locks.withWriteLock(this.getLockIdentifier(identifier), - async(): Promise => this.source.deleteResource(identifier, conditions)); + async(): Promise => this.source.deleteResource(identifier, conditions)); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.locks.withWriteLock(this.getLockIdentifier(identifier), - async(): Promise => this.source.modifyResource(identifier, patch, conditions)); + async(): Promise => this.source.modifyResource(identifier, patch, conditions)); } /** diff --git a/src/storage/MonitoringStore.ts b/src/storage/MonitoringStore.ts index d027c5ed8..0d1632de9 100644 --- a/src/storage/MonitoringStore.ts +++ b/src/storage/MonitoringStore.ts @@ -3,8 +3,9 @@ import type { Patch } from '../http/representation/Patch'; import type { Representation } from '../http/representation/Representation'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import { AS, SOLID_AS } from '../util/Vocabularies'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Store that notifies listeners of changes to its source @@ -29,31 +30,34 @@ export class MonitoringStore } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { - const identifier = await this.source.addResource(container, representation, conditions); - this.emitChanged([ container, identifier ]); - return identifier; + conditions?: Conditions): Promise { + return this.emitChanged(await this.source.addResource(container, representation, conditions)); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.emitChanged(await this.source.deleteResource(identifier, conditions)); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.emitChanged(await this.source.setRepresentation(identifier, representation, conditions)); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.emitChanged(await this.source.modifyResource(identifier, patch, conditions)); } - private emitChanged(identifiers: ResourceIdentifier[]): typeof identifiers { - for (const identifier of identifiers) { - this.emit('changed', identifier); + private emitChanged(changes: ChangeMap): ChangeMap { + for (const [ key, value ] of Object.entries(changes)) { + const activity = value.get(SOLID_AS.terms.Activity)?.value; + this.emit('changed', { path: key }, activity); + if (activity && [ AS.Create, AS.Delete, AS.Update ].includes(activity)) { + this.emit(activity, { path: key }); + } } - return identifiers; + + return changes; } } diff --git a/src/storage/PassthroughStore.ts b/src/storage/PassthroughStore.ts index 6ab9a5fc2..cf70c6a64 100644 --- a/src/storage/PassthroughStore.ts +++ b/src/storage/PassthroughStore.ts @@ -3,7 +3,7 @@ import type { Representation } from '../http/representation/Representation'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Store that calls the corresponding functions of the source Store. @@ -27,22 +27,22 @@ export class PassthroughStore implement } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.source.addResource(container, representation, conditions); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.source.deleteResource(identifier, conditions); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.source.modifyResource(identifier, patch, conditions); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return this.source.setRepresentation(identifier, representation, conditions); } } diff --git a/src/storage/PatchingStore.ts b/src/storage/PatchingStore.ts index bb13246f6..495dc815f 100644 --- a/src/storage/PatchingStore.ts +++ b/src/storage/PatchingStore.ts @@ -4,7 +4,7 @@ import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError' import type { Conditions } from './Conditions'; import { PassthroughStore } from './PassthroughStore'; import type { PatchHandler } from './patch/PatchHandler'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * {@link ResourceStore} using decorator pattern for the `modifyResource` function. @@ -20,7 +20,7 @@ export class PatchingStore extends Pass } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { try { return await this.source.modifyResource(identifier, patch, conditions); } catch (error: unknown) { diff --git a/src/storage/ReadOnlyStore.ts b/src/storage/ReadOnlyStore.ts index 8bc18e006..1b2b50e27 100644 --- a/src/storage/ReadOnlyStore.ts +++ b/src/storage/ReadOnlyStore.ts @@ -4,7 +4,7 @@ import type { ResourceIdentifier } from '../http/representation/ResourceIdentifi import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; import type { Conditions } from './Conditions'; import { PassthroughStore } from './PassthroughStore'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Store that only allow read operations on the underlying source. @@ -16,22 +16,22 @@ export class ReadOnlyStore extends Pass } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new ForbiddenHttpError(); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new ForbiddenHttpError(); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new ForbiddenHttpError(); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { throw new ForbiddenHttpError(); } } diff --git a/src/storage/RepresentationConvertingStore.ts b/src/storage/RepresentationConvertingStore.ts index 5249122ab..317c7254a 100644 --- a/src/storage/RepresentationConvertingStore.ts +++ b/src/storage/RepresentationConvertingStore.ts @@ -6,7 +6,7 @@ import type { Conditions } from './Conditions'; import { PassthroughConverter } from './conversion/PassthroughConverter'; import type { RepresentationConverter } from './conversion/RepresentationConverter'; import { PassthroughStore } from './PassthroughStore'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; /** * Store that provides (optional) conversion of incoming and outgoing {@link Representation}s. @@ -40,7 +40,7 @@ export class RepresentationConvertingStore { + conditions?: Conditions): Promise { // We can potentially run into problems here if we convert a turtle document where the base IRI is required, // since we don't know the resource IRI yet at this point. representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences }); @@ -48,7 +48,7 @@ export class RepresentationConvertingStore { + conditions?: Conditions): Promise { representation = await this.inConverter.handleSafe({ identifier, representation, preferences: this.inPreferences }); return this.source.setRepresentation(identifier, representation, conditions); } diff --git a/src/storage/ResourceStore.ts b/src/storage/ResourceStore.ts index 57bec1f30..543d741f4 100644 --- a/src/storage/ResourceStore.ts +++ b/src/storage/ResourceStore.ts @@ -1,10 +1,18 @@ import type { Patch } from '../http/representation/Patch'; import type { Representation } from '../http/representation/Representation'; +import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { Conditions } from './Conditions'; import type { ResourceSet } from './ResourceSet'; +/** + * An object containing one property for each resource that was created, updated or deleted + * by this operation. Where the key of the property is the path of the resource (string) and the value is an + * instance of RepresentationMetadata containing extra information about the change of the resource. + */ +export type ChangeMap = Record; + /** * A ResourceStore represents a collection of resources. * It has been designed such that each of its methods @@ -38,13 +46,13 @@ export interface ResourceStore extends ResourceSet { * @param representation - New representation of the resource. * @param conditions - Optional conditions under which to proceed. * - * @returns Identifiers of resources that were possibly modified. + * @returns A {@link ChangeMap}. */ setRepresentation: ( identifier: ResourceIdentifier, representation: Representation, conditions?: Conditions, - ) => Promise; + ) => Promise; /** * Creates a new resource in the container. @@ -52,25 +60,25 @@ export interface ResourceStore extends ResourceSet { * @param representation - Representation of the new resource * @param conditions - Optional conditions under which to proceed. * - * @returns The identifier of the newly created resource. + * @returns A {@link ChangeMap}. */ addResource: ( container: ResourceIdentifier, representation: Representation, conditions?: Conditions, - ) => Promise; + ) => Promise; /** * Deletes a resource. * @param identifier - Identifier of resource to delete. * @param conditions - Optional conditions under which to proceed. * - * @returns Identifiers of resources that were possibly modified. + * @returns A {@link ChangeMap}. */ deleteResource: ( identifier: ResourceIdentifier, conditions?: Conditions, - ) => Promise; + ) => Promise; /** * Sets or updates the representation of a resource, @@ -79,11 +87,11 @@ export interface ResourceStore extends ResourceSet { * @param patch - Description of which parts to update. * @param conditions - Optional conditions under which to proceed. * - * @returns Identifiers of resources that were possibly modified. + * @returns A {@link ChangeMap}. */ modifyResource: ( identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions, - ) => Promise; + ) => Promise; } diff --git a/src/storage/RoutingResourceStore.ts b/src/storage/RoutingResourceStore.ts index 0cba64bee..55d7c047e 100644 --- a/src/storage/RoutingResourceStore.ts +++ b/src/storage/RoutingResourceStore.ts @@ -5,7 +5,7 @@ import type { ResourceIdentifier } from '../http/representation/ResourceIdentifi import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import type { Conditions } from './Conditions'; -import type { ResourceStore } from './ResourceStore'; +import type { ResourceStore, ChangeMap } from './ResourceStore'; import type { RouterRule } from './routing/RouterRule'; /** @@ -31,22 +31,22 @@ export class RoutingResourceStore implements ResourceStore { } public async addResource(container: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return (await this.getStore(container, representation)).addResource(container, representation, conditions); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return (await this.getStore(identifier, representation)).setRepresentation(identifier, representation, conditions); } public async deleteResource(identifier: ResourceIdentifier, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return (await this.getStore(identifier)).deleteResource(identifier, conditions); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, - conditions?: Conditions): Promise { + conditions?: Conditions): Promise { return (await this.getStore(identifier)).modifyResource(identifier, patch, conditions); } diff --git a/src/storage/patch/PatchHandler.ts b/src/storage/patch/PatchHandler.ts index 309786d53..0edd8fb39 100644 --- a/src/storage/patch/PatchHandler.ts +++ b/src/storage/patch/PatchHandler.ts @@ -1,7 +1,7 @@ import type { Patch } from '../../http/representation/Patch'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; -import type { ResourceStore } from '../ResourceStore'; +import type { ResourceStore, ChangeMap } from '../ResourceStore'; export type PatchHandlerInput = { source: T; @@ -13,4 +13,4 @@ export type PatchHandlerInput = { * Executes the given Patch. */ export abstract class PatchHandler - extends AsyncHandler, ResourceIdentifier[]> {} + extends AsyncHandler, ChangeMap> {} diff --git a/src/storage/patch/RepresentationPatchHandler.ts b/src/storage/patch/RepresentationPatchHandler.ts index 7768f055c..1f7f9035f 100644 --- a/src/storage/patch/RepresentationPatchHandler.ts +++ b/src/storage/patch/RepresentationPatchHandler.ts @@ -1,7 +1,7 @@ import type { Representation } from '../../http/representation/Representation'; -import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../../logging/LogUtil'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { ChangeMap } from '../ResourceStore'; import type { PatchHandlerInput } from './PatchHandler'; import { PatchHandler } from './PatchHandler'; import type { RepresentationPatcher } from './RepresentationPatcher'; @@ -23,7 +23,7 @@ export class RepresentationPatchHandler extends PatchHandler { this.patcher = patcher; } - public async handle({ source, patch, identifier }: PatchHandlerInput): Promise { + public async handle({ source, patch, identifier }: PatchHandlerInput): Promise { // Get the representation from the store let representation: Representation | undefined; try { diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 2c435fbd9..cd7903d27 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -71,6 +71,12 @@ export const ACL = createUriAndTermNamespace('http://www.w3.org/ns/auth/acl#', 'Control', ); +export const AS = createUriAndTermNamespace('https://www.w3.org/ns/activitystreams#', + 'Create', + 'Delete', + 'Update', +); + export const AUTH = createUriAndTermNamespace('urn:solid:auth:', 'userMode', 'publicMode', @@ -144,6 +150,10 @@ export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms 'InsertDeletePatch', ); +export const SOLID_AS = createUriAndTermNamespace('http://www.w3.org/ns/solid/activitystreams#', + 'Activity', +); + export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:', 'disallowedMethod', 'errorResponse', diff --git a/test/unit/http/ldp/PostOperationHandler.test.ts b/test/unit/http/ldp/PostOperationHandler.test.ts index f22c9a290..db14e1a53 100644 --- a/test/unit/http/ldp/PostOperationHandler.test.ts +++ b/test/unit/http/ldp/PostOperationHandler.test.ts @@ -3,12 +3,11 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import { BasicConditions } from '../../../../src/storage/BasicConditions'; -import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import type { ResourceStore, ChangeMap } from '../../../../src/storage/ResourceStore'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; +import { AS, SOLID_AS, SOLID_HTTP } from '../../../../src/util/Vocabularies'; describe('A PostOperationHandler', (): void => { let operation: Operation; @@ -21,7 +20,16 @@ describe('A PostOperationHandler', (): void => { body = new BasicRepresentation('', 'text/turtle'); operation = { method: 'POST', target: { path: 'http://test.com/foo' }, body, conditions, preferences: {}}; store = { - addResource: jest.fn(async(): Promise => ({ path: 'newPath' } as ResourceIdentifier)), + addResource: jest.fn(async(): Promise => ({ + 'https://example.com/parent/newPath': new RepresentationMetadata( + { path: 'https://example.com/parent/newPath' }, + { [SOLID_AS.terms.Activity.value]: AS.Create }, + ), + 'https://example.com/parent/': new RepresentationMetadata( + { path: 'https://example.come/parent/' }, + { [SOLID_AS.terms.Activity.value]: AS.Update }, + ), + })), } as unknown as ResourceStore; handler = new PostOperationHandler(store); }); @@ -41,7 +49,7 @@ describe('A PostOperationHandler', (): void => { const result = await handler.handle({ operation }); expect(result.statusCode).toBe(201); expect(result.metadata).toBeInstanceOf(RepresentationMetadata); - expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('newPath'); + expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('https://example.com/parent/newPath'); expect(result.data).toBeUndefined(); expect(store.addResource).toHaveBeenCalledTimes(1); expect(store.addResource).toHaveBeenLastCalledWith(operation.target, body, conditions); diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts index 0cedc6901..454eb3d6c 100644 --- a/test/unit/storage/DataAccessorBasedStore.test.ts +++ b/test/unit/storage/DataAccessorBasedStore.test.ts @@ -23,7 +23,7 @@ import type { Guarded } from '../../../src/util/GuardedStream'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { trimTrailingSlashes } from '../../../src/util/PathUtil'; import { guardedStreamFrom } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC } from '../../../src/util/Vocabularies'; +import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC, SOLID_AS, AS } from '../../../src/util/Vocabularies'; const { namedNode, quad } = DataFactory; const GENERATED_PREDICATE = namedNode('generated'); @@ -265,11 +265,22 @@ describe('A DataAccessorBasedStore', (): void => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.terms.type); const result = await store.addResource(resourceID, representation); - expect(result).toEqual({ - path: expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')), - }); - await expect(arrayifyStream(accessor.data[result.path].data)).resolves.toEqual([ resourceData ]); - expect(accessor.data[result.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); + + expect(Object.keys(result)).toEqual([ + root, + expect.stringMatching(new RegExp(`^${root}[^/]+$`, 'u')), + ]); + + expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + + const generatedID = Object.keys(result).find((key): boolean => key !== root)!; + expect(generatedID).toBeDefined(); + expect(generatedID).toMatch(new RegExp(`^${root}[^/]+$`, 'u')); + + await expect(arrayifyStream(accessor.data[generatedID].data)).resolves.toEqual([ resourceData ]); + expect(accessor.data[generatedID]).toBeTruthy(); + expect(accessor.data[generatedID].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); + expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); }); it('can write containers.', async(): Promise => { @@ -278,16 +289,25 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.contentType = 'text/turtle'; representation.data = guardedStreamFrom([ '<> a .' ]); const result = await store.addResource(resourceID, representation); - expect(result).toEqual({ - path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')), - }); - expect(accessor.data[result.path]).toBeTruthy(); - expect(accessor.data[result.path].metadata.contentType).toBeUndefined(); - const { data, metadata } = await store.getRepresentation(result); + expect(Object.keys(result)).toEqual([ + root, + expect.stringMatching(new RegExp(`^${root}[^/]+?/$`, 'u')), + ]); + + expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + + const generatedID = Object.keys(result).find((key): boolean => key !== root)!; + expect(generatedID).toBeDefined(); + expect(generatedID).toMatch(new RegExp(`^${root}[^/]+?/$`, 'u')); + expect(accessor.data[generatedID]).toBeTruthy(); + expect(accessor.data[generatedID].metadata.contentType).toBeUndefined(); + expect(result[generatedID].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); + + const { data, metadata } = await store.getRepresentation({ path: generatedID }); const quads = await arrayifyStream(data); expect(metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); - expect(quads.some((entry): boolean => entry.subject.value === result.path && + expect(quads.some((entry): boolean => entry.subject.value === generatedID && entry.object.value === 'http://test.com/coolContainer')).toBeTruthy(); }); @@ -295,10 +315,14 @@ describe('A DataAccessorBasedStore', (): void => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.terms.type); representation.metadata.add(SOLID_HTTP.terms.slug, 'newName'); - const result = await store.addResource(resourceID, representation); - expect(result).toEqual({ - path: `${root}newName`, + + const result = store.addResource(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [`${root}newName`]: expect.any(RepresentationMetadata), }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[`${root}newName`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); }); it('errors on a slug ending on / without Link rel:type Container header.', async(): Promise => { @@ -320,10 +344,14 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.add(RDF.terms.type, LDP.terms.Container); representation.metadata.add(SOLID_HTTP.terms.slug, 'newContainer'); representation.data = guardedStreamFrom([ `` ]); + const result = await store.addResource(resourceID, representation); expect(result).toEqual({ - path: `${root}newContainer/`, + [root]: expect.any(RepresentationMetadata), + [`${root}newContainer/`]: expect.any(RepresentationMetadata), }); + expect(result[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect(result[`${root}newContainer/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); }); it('generates a new URI if adding the slug would create an existing URI.', async(): Promise => { @@ -331,20 +359,26 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.add(SOLID_HTTP.terms.slug, 'newName'); accessor.data[`${root}newName`] = representation; const result = await store.addResource(resourceID, representation); - expect(result).not.toEqual({ - path: `${root}newName`, - }); - expect(result).not.toEqual({ - path: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')), - }); + expect(result).not.toEqual(expect.objectContaining({ + [`${root}newName`]: expect.any(RepresentationMetadata), + })); + expect(result).not.toEqual(expect.objectContaining({ + [expect.any(String)]: expect.stringMatching(new RegExp(`^${root}[^/]+/$`, 'u')), + })); }); it('generates http://test.com/%26%26 when slug is &%26.', async(): Promise => { const resourceID = { path: root }; representation.metadata.removeAll(RDF.terms.type); representation.metadata.add(SOLID_HTTP.terms.slug, '&%26'); - const result = await store.addResource(resourceID, representation); - expect(result).toEqual({ path: `${root}%26%26` }); + + const result = store.addResource(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [`${root}%26%26`]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[`${root}%26%26`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); }); it('errors if the slug contains a slash.', async(): Promise => { @@ -408,8 +442,11 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.contentType = 'text/turtle'; representation.data = guardedStreamFrom([ `<${root}> a .` ]); - await expect(store.setRepresentation(resourceID, representation)).resolves - .toEqual([{ path: `${root}` }]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); expect(mock).toHaveBeenCalledTimes(1); expect(mock).toHaveBeenLastCalledWith(resourceID); @@ -438,10 +475,13 @@ describe('A DataAccessorBasedStore', (): void => { it('can write resources.', async(): Promise => { const resourceID = { path: `${root}resource` }; - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: root }, - { path: `${root}resource` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); @@ -455,10 +495,13 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.removeAll(RDF.terms.type); representation.metadata.contentType = 'text/turtle'; representation.data = guardedStreamFrom([ `<${root}resource/> a .` ]); - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: root }, - { path: `${root}container/` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); @@ -468,10 +511,13 @@ describe('A DataAccessorBasedStore', (): void => { it('can overwrite resources which does not update parent metadata.', async(): Promise => { const resourceID = { path: `${root}resource` }; - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: root }, - { path: `${root}resource` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); @@ -479,9 +525,11 @@ describe('A DataAccessorBasedStore', (): void => { // Parent metadata does not get updated if the resource already exists representation = new BasicRepresentation('updatedText', 'text/plain'); mockDate.mockReturnValue(later); - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: `${root}resource` }, - ]); + const result2 = store.setRepresentation(resourceID, representation); + await expect(result2).resolves.toEqual({ + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result2)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ 'updatedText' ]); expect(accessor.data[resourceID.path].metadata.get(DC.terms.modified)?.value).toBe(later.toISOString()); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); @@ -492,10 +540,13 @@ describe('A DataAccessorBasedStore', (): void => { const resourceID = { path: `${root}resource` }; representation.metadata.add(namedNode('notGen'), 'value'); representation.metadata.add(namedNode('gen'), 'value', SOLID_META.terms.ResponseMetadata); - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: root }, - { path: `${root}resource` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); expect(accessor.data[resourceID.path].metadata.get(namedNode('notGen'))?.value).toBe('value'); expect(accessor.data[resourceID.path].metadata.get(namedNode('gen'))).toBeUndefined(); @@ -505,10 +556,13 @@ describe('A DataAccessorBasedStore', (): void => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete accessor.data[root]; const resourceID = { path: `${root}resource` }; - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: `${root}` }, - { path: `${root}resource` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); }); @@ -521,10 +575,13 @@ describe('A DataAccessorBasedStore', (): void => { representation.data = guardedStreamFrom( [ quad(namedNode(`${root}resource/`), namedNode('a'), namedNode('coolContainer')) ], ); - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: `${root}` }, - { path: `${root}container/` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); expect(accessor.data[resourceID.path]).toBeTruthy(); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); }); @@ -544,11 +601,17 @@ describe('A DataAccessorBasedStore', (): void => { it('creates recursive containers when needed.', async(): Promise => { const resourceID = { path: `${root}a/b/resource` }; - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: `${root}a/` }, - { path: `${root}a/b/` }, - { path: `${root}a/b/resource` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [`${root}`]: expect.any(RepresentationMetadata), + [`${root}a/`]: expect.any(RepresentationMetadata), + [`${root}a/b/`]: expect.any(RepresentationMetadata), + [`${root}a/b/resource`]: expect.any(RepresentationMetadata), + }); + expect((await result)[`${root}`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[`${root}a/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); + expect((await result)[`${root}a/b/`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); + expect((await result)[`${root}a/b/resource`].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); await expect(arrayifyStream(accessor.data[resourceID.path].data)).resolves.toEqual([ resourceData ]); expect(accessor.data[`${root}a/`].metadata.getAll(RDF.terms.type).map((type): string => type.value)) .toContain(LDP.Container); @@ -573,9 +636,11 @@ describe('A DataAccessorBasedStore', (): void => { representation.metadata.removeAll(RDF.terms.type); representation.metadata.contentType = 'text/turtle'; representation.data = guardedStreamFrom([]); - await expect(store.setRepresentation(resourceID, representation)).resolves.toEqual([ - { path: `${root}` }, - ]); + const result = store.setRepresentation(resourceID, representation); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + }); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Create); expect(accessor.data[resourceID.path]).toBeTruthy(); expect(Object.keys(accessor.data)).toHaveLength(1); expect(accessor.data[resourceID.path].metadata.contentType).toBeUndefined(); @@ -658,51 +723,70 @@ describe('A DataAccessorBasedStore', (): void => { }); it('will delete resources.', async(): Promise => { - accessor.data[`${root}resource`] = representation; - await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([ - { path: `${root}resource` }, - { path: root }, - ]); - expect(accessor.data[`${root}resource`]).toBeUndefined(); + const resourceID = { path: `${root}resource` }; + accessor.data[resourceID.path] = representation; + await expect(store.deleteResource(resourceID)).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect(accessor.data[resourceID.path]).toBeUndefined(); expect(accessor.data[root].metadata.get(DC.terms.modified)?.value).toBe(now.toISOString()); expect(accessor.data[root].metadata.get(GENERATED_PREDICATE)).toBeUndefined(); }); it('will delete root non-storage containers.', async(): Promise => { accessor.data[root] = new BasicRepresentation(representation.data, containerMetadata); - await expect(store.deleteResource({ path: root })).resolves.toEqual([ - { path: root }, - ]); + await expect(store.deleteResource({ path: root })).resolves.toEqual( + { [root]: expect.any(RepresentationMetadata) }, + ); expect(accessor.data[root]).toBeUndefined(); }); it('will delete a root storage auxiliary resource of a non-root container.', async(): Promise => { + const resourceID = { path: `${root}container/` }; + const auxResourceID = { path: `${root}container/.dummy` }; const storageMetadata = new RepresentationMetadata(representation.metadata); - accessor.data[`${root}container/`] = new BasicRepresentation(representation.data, storageMetadata); - accessor.data[`${root}container/.dummy`] = representation; + accessor.data[resourceID.path] = new BasicRepresentation(representation.data, storageMetadata); + accessor.data[auxResourceID.path] = representation; auxiliaryStrategy.isRequiredInRoot = jest.fn().mockReturnValue(true); - await expect(store.deleteResource({ path: `${root}container/.dummy` })).resolves.toEqual([ - { path: `${root}container/.dummy` }, - { path: `${root}container/` }, - ]); - expect(accessor.data[`${root}container/.dummy`]).toBeUndefined(); + const result = store.deleteResource(auxResourceID); + await expect(result).resolves.toEqual( + expect.objectContaining({ + [resourceID.path]: expect.any(RepresentationMetadata), + [auxResourceID.path]: expect.any(RepresentationMetadata), + }), + ); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete); + expect(accessor.data[auxResourceID.path]).toBeUndefined(); }); it('will delete related auxiliary resources.', async(): Promise => { - accessor.data[`${root}container/`] = representation; - accessor.data[`${root}container/.dummy`] = representation; - await expect(store.deleteResource({ path: `${root}container/` })).resolves.toEqual([ - { path: `${root}container/` }, - { path: `${root}container/.dummy` }, - { path: root }, - ]); - expect(accessor.data[`${root}container/`]).toBeUndefined(); - expect(accessor.data[`${root}container/.dummy`]).toBeUndefined(); + const resourceID = { path: `${root}container/` }; + const auxResourceID = { path: `${root}container/.dummy` }; + accessor.data[resourceID.path] = representation; + accessor.data[auxResourceID.path] = representation; + + const result = store.deleteResource(resourceID); + await expect(result).resolves.toEqual( + expect.objectContaining({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + [auxResourceID.path]: expect.any(RepresentationMetadata), + }), + ); + expect((await result)[root].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Update); + expect((await result)[resourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete); + expect((await result)[auxResourceID.path].get(SOLID_AS.terms.Activity)?.value).toBe(AS.Delete); + expect(accessor.data[resourceID.path]).toBeUndefined(); + expect(accessor.data[auxResourceID.path]).toBeUndefined(); }); it('will still delete a resource if deleting auxiliary resources causes errors.', async(): Promise => { - accessor.data[`${root}resource`] = representation; - accessor.data[`${root}resource.dummy`] = representation; + const resourceID = { path: `${root}resource` }; + const auxResourceID = { path: `${root}resource.dummy` }; + accessor.data[resourceID.path] = representation; + accessor.data[auxResourceID.path] = representation; const deleteFn = accessor.deleteResource; accessor.deleteResource = jest.fn(async(identifier: ResourceIdentifier): Promise => { if (auxiliaryStrategy.isAuxiliaryIdentifier(identifier)) { @@ -712,12 +796,14 @@ describe('A DataAccessorBasedStore', (): void => { }); const { logger } = store as any; logger.error = jest.fn(); - await expect(store.deleteResource({ path: `${root}resource` })).resolves.toEqual([ - { path: `${root}resource` }, - { path: root }, - ]); - expect(accessor.data[`${root}resource`]).toBeUndefined(); - expect(accessor.data[`${root}resource.dummy`]).toBeDefined(); + const result = store.deleteResource(resourceID); + expect(Object.keys(await result)).toHaveLength(2); + await expect(result).resolves.toEqual({ + [root]: expect.any(RepresentationMetadata), + [resourceID.path]: expect.any(RepresentationMetadata), + }); + expect(accessor.data[resourceID.path]).toBeUndefined(); + expect(accessor.data[auxResourceID.path]).toBeDefined(); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenLastCalledWith( 'Error deleting auxiliary resource http://test.com/resource.dummy: auxiliary error!', diff --git a/test/unit/storage/MonitoringStore.test.ts b/test/unit/storage/MonitoringStore.test.ts index d8f32bad7..6de70fe5f 100644 --- a/test/unit/storage/MonitoringStore.test.ts +++ b/test/unit/storage/MonitoringStore.test.ts @@ -1,33 +1,86 @@ import type { Patch } from '../../../src/http/representation/Patch'; import type { Representation } from '../../../src/http/representation/Representation'; +import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import { MonitoringStore } from '../../../src/storage/MonitoringStore'; import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { AS, SOLID_AS } from '../../../src/util/Vocabularies'; describe('A MonitoringStore', (): void => { let store: MonitoringStore; let source: ResourceStore; + let changedCallback: () => void; - const modified = [ - { path: 'http://example.org/modified/1' }, - { path: 'http://example.org/modified/2' }, - ]; + let createdCallback: () => void; + let updatedCallback: () => void; + let deletedCallback: () => void; + + const addResourceReturnMock = { + 'http://example.org/foo/bar/new': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/new' }, + { [SOLID_AS.terms.Activity.value]: AS.Create }, + ), + 'http://example.org/foo/bar/': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/' }, + { [SOLID_AS.terms.Activity.value]: AS.Update }, + ), + }; + const setRepresentationReturnMock = { + 'http://example.org/foo/bar/new': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/new' }, + { [SOLID_AS.terms.Activity.value]: AS.Update }, + ), + }; + const deleteResourceReturnMock = { + 'http://example.org/foo/bar/new': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/new' }, + { [SOLID_AS.terms.Activity.value]: AS.Delete }, + ), + 'http://example.org/foo/bar/': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/' }, + { [SOLID_AS.terms.Activity.value]: AS.Update }, + ), + }; + const modifyResourceReturnMock = { + 'http://example.org/foo/bar/old': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/new' }, + { [SOLID_AS.terms.Activity.value]: AS.Delete }, + ), + 'http://example.org/foo/bar/new': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/new' }, + { [SOLID_AS.terms.Activity.value]: AS.Create }, + ), + 'http://example.org/foo/bar/': new RepresentationMetadata( + { path: 'http://example.org/foo/bar/' }, + { [SOLID_AS.terms.Activity.value]: AS.Update }, + ), + }; beforeEach(async(): Promise => { source = { getRepresentation: jest.fn(async(): Promise => ({ success: true })), - addResource: jest.fn(async(): Promise => ({ path: 'http://example.org/foo/bar/new' })), - setRepresentation: jest.fn(async(): Promise => modified), - deleteResource: jest.fn(async(): Promise => modified), - modifyResource: jest.fn(async(): Promise => modified), + addResource: jest.fn(async(): Promise => addResourceReturnMock), + setRepresentation: jest.fn(async(): Promise => setRepresentationReturnMock), + deleteResource: jest.fn(async(): Promise => deleteResourceReturnMock), + modifyResource: jest.fn(async(): Promise => modifyResourceReturnMock), hasResource: jest.fn(async(): Promise => undefined), }; store = new MonitoringStore(source); + changedCallback = jest.fn(); + createdCallback = jest.fn(); + updatedCallback = jest.fn(); + deletedCallback = jest.fn(); store.on('changed', changedCallback); + store.on(AS.Create, createdCallback); + store.on(AS.Update, updatedCallback); + store.on(AS.Delete, deletedCallback); }); afterEach(async(): Promise => { store.removeListener('changed', changedCallback); + store.removeListener(AS.Create, createdCallback); + store.removeListener(AS.Update, updatedCallback); + store.removeListener(AS.Delete, deletedCallback); }); it('calls getRepresentation directly from the source.', async(): Promise => { @@ -43,67 +96,89 @@ describe('A MonitoringStore', (): void => { }); it('calls addResource directly from the source.', async(): Promise => { - await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves - .toStrictEqual({ path: 'http://example.org/foo/bar/new' }); + await expect(store.addResource({ path: 'http://example.org/foo/bar' }, {} as Representation)).resolves.toEqual({ + 'http://example.org/foo/bar/new': expect.any(RepresentationMetadata), + 'http://example.org/foo/bar/': expect.any(RepresentationMetadata), + }); expect(source.addResource).toHaveBeenCalledTimes(1); expect(source.addResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); }); - it('fires container and resource change events after addResource.', async(): Promise => { + it('fires appropriate events according to the return value of source.addResource.', async(): Promise => { const result = store.addResource({ path: 'http://example.org/foo/bar/' }, {} as Representation); expect(changedCallback).toHaveBeenCalledTimes(0); await result; expect(changedCallback).toHaveBeenCalledTimes(2); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create); + expect(createdCallback).toHaveBeenCalledTimes(1); + expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); + expect(updatedCallback).toHaveBeenCalledTimes(1); + expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); + expect(deletedCallback).toHaveBeenCalledTimes(0); }); it('calls setRepresentation directly from the source.', async(): Promise => { await expect(store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation)) - .resolves.toEqual(modified); + .resolves.toEqual(setRepresentationReturnMock); expect(source.setRepresentation).toHaveBeenCalledTimes(1); expect(source.setRepresentation).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); }); - it('fires all modified change events after setRepresentation.', async(): Promise => { + it('fires appropriate events according to the return value of source.setRepresentation.', async(): Promise => { const result = store.setRepresentation({ path: 'http://example.org/foo/bar' }, {} as Representation); expect(changedCallback).toHaveBeenCalledTimes(0); await result; - expect(changedCallback).toHaveBeenCalledTimes(2); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); + expect(changedCallback).toHaveBeenCalledTimes(1); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Update); + expect(createdCallback).toHaveBeenCalledTimes(0); + expect(updatedCallback).toHaveBeenCalledTimes(1); + expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); + expect(deletedCallback).toHaveBeenCalledTimes(0); }); it('calls deleteResource directly from the source.', async(): Promise => { await expect(store.deleteResource({ path: 'http://example.org/foo/bar' })) - .resolves.toEqual(modified); + .resolves.toEqual(deleteResourceReturnMock); expect(source.deleteResource).toHaveBeenCalledTimes(1); expect(source.deleteResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, undefined); }); - it('fires all modified change events after deleteResource.', async(): Promise => { + it('fires appropriate events according to the return value of source.deleteResource.', async(): Promise => { const result = store.deleteResource({ path: 'http://example.org/foo/bar' }); expect(changedCallback).toHaveBeenCalledTimes(0); await result; expect(changedCallback).toHaveBeenCalledTimes(2); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Delete); + expect(createdCallback).toHaveBeenCalledTimes(0); + expect(updatedCallback).toHaveBeenCalledTimes(1); + expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); + expect(deletedCallback).toHaveBeenCalledTimes(1); + expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); }); it('calls modifyResource directly from the source.', async(): Promise => { await expect(store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch)) - .resolves.toEqual(modified); + .resolves.toEqual(modifyResourceReturnMock); expect(source.modifyResource).toHaveBeenCalledTimes(1); expect(source.modifyResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }, {}, undefined); }); - it('fires all modified change events after modifyResource.', async(): Promise => { + it('fires appropriate events according to the return value of source.modifyResource.', async(): Promise => { const result = store.modifyResource({ path: 'http://example.org/foo/bar' }, {} as Patch); expect(changedCallback).toHaveBeenCalledTimes(0); await result; - expect(changedCallback).toHaveBeenCalledTimes(2); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/1' }); - expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/modified/2' }); + expect(changedCallback).toHaveBeenCalledTimes(3); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }, AS.Create); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }, AS.Update); + expect(changedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }, AS.Delete); + expect(createdCallback).toHaveBeenCalledTimes(1); + expect(createdCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/new' }); + expect(updatedCallback).toHaveBeenCalledTimes(1); + expect(updatedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/' }); + expect(deletedCallback).toHaveBeenCalledTimes(1); + expect(deletedCallback).toHaveBeenCalledWith({ path: 'http://example.org/foo/bar/old' }); }); it('calls hasResource directly from the source.', async(): Promise => { @@ -111,4 +186,20 @@ describe('A MonitoringStore', (): void => { expect(source.hasResource).toHaveBeenCalledTimes(1); expect(source.hasResource).toHaveBeenLastCalledWith({ path: 'http://example.org/foo/bar' }); }); + + it('should not emit an extra event when the Activity is not a valid AS value.', async(): Promise => { + source.addResource = jest.fn(async(): Promise => ({ + 'http://example.com/path': new RepresentationMetadata( + { path: 'http://example.com/path' }, + { [SOLID_AS.terms.Activity.value]: 'SomethingRandom' }, + ), + })); + + await store.addResource({ path: 'http://example.org/foo/bar' }, {} as Patch); + + expect(changedCallback).toHaveBeenCalledTimes(1); + expect(createdCallback).toHaveBeenCalledTimes(0); + expect(updatedCallback).toHaveBeenCalledTimes(0); + expect(deletedCallback).toHaveBeenCalledTimes(0); + }); }); diff --git a/test/unit/storage/patch/RepresentationPatchHandler.test.ts b/test/unit/storage/patch/RepresentationPatchHandler.test.ts index 91b302b69..0a0829738 100644 --- a/test/unit/storage/patch/RepresentationPatchHandler.test.ts +++ b/test/unit/storage/patch/RepresentationPatchHandler.test.ts @@ -1,5 +1,6 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Patch } from '../../../../src/http/representation/Patch'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import type { PatchHandlerInput } from '../../../../src/storage/patch/PatchHandler'; import type { RepresentationPatcher } from '../../../../src/storage/patch/RepresentationPatcher'; import { RepresentationPatchHandler } from '../../../../src/storage/patch/RepresentationPatchHandler'; @@ -20,7 +21,9 @@ describe('A RepresentationPatchHandler', (): void => { beforeEach(async(): Promise => { source = { getRepresentation: jest.fn().mockResolvedValue(representation), - setRepresentation: jest.fn().mockResolvedValue([ identifier ]), + setRepresentation: jest.fn().mockResolvedValue({ + [identifier.path]: new RepresentationMetadata(identifier), + }), } as any; input = { source, identifier, patch }; @@ -33,7 +36,9 @@ describe('A RepresentationPatchHandler', (): void => { }); it('calls the patcher with the representation from the store.', async(): Promise => { - await expect(handler.handle(input)).resolves.toEqual([ identifier ]); + await expect(handler.handle(input)).resolves.toEqual({ + [identifier.path]: new RepresentationMetadata(identifier), + }); expect(patcher.handleSafe).toHaveBeenCalledTimes(1); expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch, representation }); @@ -45,7 +50,9 @@ describe('A RepresentationPatchHandler', (): void => { it('calls the patcher with no representation if there is none.', async(): Promise => { source.getRepresentation.mockRejectedValueOnce(new NotFoundHttpError()); - await expect(handler.handle(input)).resolves.toEqual([ identifier ]); + await expect(handler.handle(input)).resolves.toEqual({ + [identifier.path]: new RepresentationMetadata(identifier), + }); expect(patcher.handleSafe).toHaveBeenCalledTimes(1); expect(patcher.handleSafe).toHaveBeenLastCalledWith({ identifier, patch });