diff --git a/build/compile-validators.js b/build/compile-validators.js index 28b674541..9d9bc0c43 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -24,6 +24,7 @@ import Definitions from '../json-schemas/definitions.json' assert { type: 'json' import EventsFilter from '../json-schemas/interface-methods/events-filter.json' assert { type: 'json' }; import EventsGet from '../json-schemas/interface-methods/events-get.json' assert { type: 'json' }; import EventsQuery from '../json-schemas/interface-methods/events-query.json' assert { type: 'json' }; +import EventsSubscribe from '../json-schemas/interface-methods/events-subscribe.json' assert { type: 'json' }; import GeneralJwk from '../json-schemas/jwk/general-jwk.json' assert { type: 'json' }; import GeneralJws from '../json-schemas/general-jws.json' assert { type: 'json' }; import GenericSignaturePayload from '../json-schemas/signature-payloads/generic-signature-payload.json' assert { type: 'json' }; @@ -60,6 +61,7 @@ const schemas = { EventsFilter, EventsGet, EventsQuery, + EventsSubscribe, Definitions, GeneralJwk, GeneralJws, diff --git a/json-schemas/interface-methods/events-subscribe.json b/json-schemas/interface-methods/events-subscribe.json new file mode 100644 index 000000000..99bbc25b3 --- /dev/null +++ b/json-schemas/interface-methods/events-subscribe.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://identity.foundation/dwn/json-schemas/events-subscribe.json", + "type": "object", + "additionalProperties": false, + "required": [ + "descriptor", + "authorization" + ], + "properties": { + "authorization": { + "$ref": "https://identity.foundation/dwn/json-schemas/authorization.json" + }, + "descriptor": { + "type": "object", + "additionalProperties": false, + "required": [ + "interface", + "method", + "messageTimestamp", + "filters" + ], + "properties": { + "interface": { + "enum": [ + "Events" + ], + "type": "string" + }, + "method": { + "enum": [ + "Subscribe" + ], + "type": "string" + }, + "messageTimestamp": { + "type": "string" + }, + "filters": { + "type": "array", + "items": { + "$ref": "https://identity.foundation/dwn/json-schemas/events-filter.json" + } + } + } + } + } + } diff --git a/package-lock.json b/package-lock.json index fcccff2e6..a49da66f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "license": "Apache-2.0", "dependencies": { "@ipld/dag-cbor": "9.0.3", diff --git a/package.json b/package.json index 8d2b3898a..cf91c82e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.2.12", + "version": "0.2.13", "description": "A reference implementation of https://identity.foundation/decentralized-web-node/spec/", "repository": { "type": "git", diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 0243ee8f1..bbe2e9440 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -25,6 +25,7 @@ export enum DwnErrorCode { DidNotValid = 'DidNotValid', DidResolutionFailed = 'DidResolutionFailed', Ed25519InvalidJwk = 'Ed25519InvalidJwk', + EventsSubscribeEventStreamUnimplemented = 'EventsSubscribeEventStreamUnimplemented', GeneralJwsVerifierGetPublicKeyNotFound = 'GeneralJwsVerifierGetPublicKeyNotFound', GeneralJwsVerifierInvalidSignature = 'GeneralJwsVerifierInvalidSignature', GrantAuthorizationGrantExpired = 'GrantAuthorizationGrantExpired', diff --git a/src/core/message-reply.ts b/src/core/message-reply.ts index 57b1fcf57..decd58758 100644 --- a/src/core/message-reply.ts +++ b/src/core/message-reply.ts @@ -3,7 +3,7 @@ import type { PaginationCursor } from '../types/query-types.js'; import type { ProtocolsConfigureMessage } from '../types/protocols-types.js'; import type { Readable } from 'readable-stream'; import type { RecordsWriteMessage } from '../types/records-types.js'; -import type { GenericMessageReply, QueryResultEntry } from '../types/message-types.js'; +import type { GenericMessageReply, MessageSubscription, QueryResultEntry } from '../types/message-types.js'; export function messageReplyFromError(e: unknown, code: number): GenericMessageReply { @@ -40,4 +40,9 @@ export type UnionMessageReply = GenericMessageReply & { * Mutually exclusive with `record`. */ cursor?: PaginationCursor; + + /** + * A subscription object if a subscription was requested. + */ + subscription?: MessageSubscription; }; \ No newline at end of file diff --git a/src/dwn.ts b/src/dwn.ts index ad6c3090d..efab692d7 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -1,10 +1,11 @@ import type { DataStore } from './types/data-store.js'; import type { EventLog } from './types/event-log.js'; +import type { EventStream } from './types/subscriptions.js'; import type { MessageStore } from './types/message-store.js'; import type { MethodHandler } from './types/method-handler.js'; import type { TenantGate } from './core/tenant-gate.js'; import type { UnionMessageReply } from './core/message-reply.js'; -import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply } from './types/event-types.js'; +import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeMessageOptions, EventsSubscribeReply } from './types/events-types.js'; import type { GenericMessage, GenericMessageReply, MessageOptions } from './types/message-types.js'; import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from './types/permissions-types.js'; @@ -15,6 +16,7 @@ import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DidResolver } from './did/did-resolver.js'; import { EventsGetHandler } from './handlers/events-get.js'; import { EventsQueryHandler } from './handlers/events-query.js'; +import { EventsSubscribeHandler } from './handlers/events-subscribe.js'; import { Message } from './core/message.js'; import { messageReplyFromError } from './core/message-reply.js'; import { MessagesGetHandler } from './handlers/messages-get.js'; @@ -36,6 +38,7 @@ export class Dwn { private dataStore: DataStore; private eventLog: EventLog; private tenantGate: TenantGate; + private eventStream?: EventStream; private constructor(config: DwnConfig) { this.didResolver = config.didResolver!; @@ -43,25 +46,79 @@ export class Dwn { this.messageStore = config.messageStore; this.dataStore = config.dataStore; this.eventLog = config.eventLog; + this.eventStream = config.eventStream; this.methodHandlers = { - [DwnInterfaceName.Events + DwnMethodName.Get] : new EventsGetHandler(this.didResolver, this.eventLog), - [DwnInterfaceName.Events + DwnMethodName.Query] : new EventsQueryHandler(this.didResolver, this.eventLog), - [DwnInterfaceName.Messages + DwnMethodName.Get] : new MessagesGetHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Permissions + DwnMethodName.Grant] : new PermissionsGrantHandler( - this.didResolver, this.messageStore, this.eventLog), + [DwnInterfaceName.Events + DwnMethodName.Get]: new EventsGetHandler( + this.didResolver, + this.eventLog, + ), + [DwnInterfaceName.Events + DwnMethodName.Query]: new EventsQueryHandler( + this.didResolver, + this.eventLog, + ), + [DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler( + this.didResolver, + this.eventStream, + ), + [DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler( + this.didResolver, + this.messageStore, + this.dataStore, + ), + [DwnInterfaceName.Permissions + DwnMethodName.Grant]: new PermissionsGrantHandler( + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Permissions + DwnMethodName.Request]: new PermissionsRequestHandler( - this.didResolver, this.messageStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Permissions + DwnMethodName.Revoke]: new PermissionsRevokeHandler( - this.didResolver, this.messageStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), [DwnInterfaceName.Protocols + DwnMethodName.Configure]: new ProtocolsConfigureHandler( - this.didResolver, this.messageStore, this.dataStore, this.eventLog), - [DwnInterfaceName.Protocols + DwnMethodName.Query] : new ProtocolsQueryHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Delete] : new RecordsDeleteHandler( - this.didResolver, this.messageStore, this.dataStore, this.eventLog), - [DwnInterfaceName.Records + DwnMethodName.Query] : new RecordsQueryHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Read] : new RecordsReadHandler(this.didResolver, this.messageStore, this.dataStore), - [DwnInterfaceName.Records + DwnMethodName.Write] : new RecordsWriteHandler(this.didResolver, this.messageStore, this.dataStore, this.eventLog), + this.didResolver, + this.messageStore, + this.eventLog, + this.eventStream + ), + [DwnInterfaceName.Protocols + DwnMethodName.Query]: new ProtocolsQueryHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Delete]: new RecordsDeleteHandler( + this.didResolver, + this.messageStore, + this.dataStore, + this.eventLog, + this.eventStream + ), + [DwnInterfaceName.Records + DwnMethodName.Query]: new RecordsQueryHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Read]: new RecordsReadHandler( + this.didResolver, + this.messageStore, + this.dataStore + ), + [DwnInterfaceName.Records + DwnMethodName.Write]: new RecordsWriteHandler( + this.didResolver, + this.messageStore, + this.dataStore, + this.eventLog, + this.eventStream + ) }; } @@ -82,12 +139,14 @@ export class Dwn { await this.messageStore.open(); await this.dataStore.open(); await this.eventLog.open(); + await this.eventStream?.open(); } public async close(): Promise { - this.messageStore.close(); - this.dataStore.close(); - this.eventLog.close(); + await this.eventStream?.close(); + await this.messageStore.close(); + await this.dataStore.close(); + await this.eventLog.close(); } /** @@ -96,6 +155,8 @@ export class Dwn { */ public async processMessage(tenant: string, rawMessage: EventsGetMessage): Promise; public async processMessage(tenant: string, rawMessage: EventsQueryMessage): Promise; + public async processMessage( + tenant: string, rawMessage: EventsSubscribeMessage, options?: EventsSubscribeMessageOptions): Promise; public async processMessage(tenant: string, rawMessage: MessagesGetMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsConfigureMessage): Promise; public async processMessage(tenant: string, rawMessage: ProtocolsQueryMessage): Promise; @@ -113,13 +174,14 @@ export class Dwn { return errorMessageReply; } - const { dataStream } = options; + const { dataStream, subscriptionHandler } = options; const handlerKey = rawMessage.descriptor.interface + rawMessage.descriptor.method; const methodHandlerReply = await this.methodHandlers[handlerKey].handle({ tenant, message: rawMessage as GenericMessage, - dataStream + dataStream, + subscriptionHandler }); return methodHandlerReply; @@ -154,6 +216,7 @@ export class Dwn { // Verify interface and method const dwnInterface = rawMessage?.descriptor?.interface; const dwnMethod = rawMessage?.descriptor?.method; + if (dwnInterface === undefined || dwnMethod === undefined) { return { status: { code: 400, detail: `Both interface and method must be present, interface: ${dwnInterface}, method: ${dwnMethod}` } @@ -174,10 +237,13 @@ export class Dwn { * DWN configuration. */ export type DwnConfig = { - didResolver?: DidResolver, + didResolver?: DidResolver; tenantGate?: TenantGate; + // event stream is optional if a DWN does not wish to provide subscription services. + eventStream?: EventStream; + messageStore: MessageStore; dataStore: DataStore; - eventLog: EventLog + eventLog: EventLog; }; diff --git a/src/enums/dwn-interface-method.ts b/src/enums/dwn-interface-method.ts index a1e567aa7..21cd7aaa5 100644 --- a/src/enums/dwn-interface-method.ts +++ b/src/enums/dwn-interface-method.ts @@ -16,5 +16,6 @@ export enum DwnMethodName { Request = 'Request', Revoke = 'Revoke', Write = 'Write', - Delete = 'Delete' + Delete = 'Delete', + Subscribe = 'Subscribe' } diff --git a/src/event-log/event-emitter-stream.ts b/src/event-log/event-emitter-stream.ts new file mode 100644 index 000000000..787e38cca --- /dev/null +++ b/src/event-log/event-emitter-stream.ts @@ -0,0 +1,49 @@ +import type { GenericMessage } from '../types/message-types.js'; +import type { KeyValues } from '../types/query-types.js'; +import type { EventListener, EventStream, EventSubscription } from '../types/subscriptions.js'; + +import { EventEmitter } from 'events'; + +const EVENTS_LISTENER_CHANNEL = 'events'; + +export class EventEmitterStream implements EventStream { + private eventEmitter: EventEmitter; + private isOpen: boolean = false; + + constructor() { + // we capture the rejections and currently just log the errors that are produced + this.eventEmitter = new EventEmitter({ captureRejections: true }); + this.eventEmitter.on('error', this.eventError); + } + + // we subscribe to the general `EventEmitter` error events with this handler. + // this handler is also called when there is a caught error upon emitting an event from a handler. + private eventError(error: any): void { + console.error('event emitter error', error); + }; + + async subscribe(id: string, listener: EventListener): Promise { + this.eventEmitter.on(EVENTS_LISTENER_CHANNEL, listener); + return { + id, + close: async (): Promise => { this.eventEmitter.off(EVENTS_LISTENER_CHANNEL, listener); } + }; + } + + async open(): Promise { + this.isOpen = true; + } + + async close(): Promise { + this.isOpen = false; + this.eventEmitter.removeAllListeners(); + } + + emit(tenant: string, message: GenericMessage, indexes: KeyValues): void { + if (!this.isOpen) { + console.error('message emitted when EventEmitterStream is closed', tenant, message, indexes); + return; + } + this.eventEmitter.emit(EVENTS_LISTENER_CHANNEL, tenant, message, indexes); + } +} \ No newline at end of file diff --git a/src/event-log/event-log-level.ts b/src/event-log/event-log-level.ts index 372ba1f96..581b1bc46 100644 --- a/src/event-log/event-log-level.ts +++ b/src/event-log/event-log-level.ts @@ -1,4 +1,5 @@ import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { ULIDFactory } from 'ulidx'; import type { Filter, KeyValues, PaginationCursor } from '../types/query-types.js'; @@ -14,6 +15,7 @@ type EventLogLevelConfig = { */ location?: string, createLevelDatabase?: typeof createLevelDatabase, + eventStream?: EventStream, }; export class EventLogLevel implements EventLog { diff --git a/src/handlers/events-get.ts b/src/handlers/events-get.ts index 25b4084c0..31f459061 100644 --- a/src/handlers/events-get.ts +++ b/src/handlers/events-get.ts @@ -1,7 +1,7 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsGetMessage, EventsGetReply } from '../types/event-types.js'; +import type { EventsGetMessage, EventsGetReply } from '../types/events-types.js'; import { EventsGet } from '../interfaces/events-get.js'; import { messageReplyFromError } from '../core/message-reply.js'; diff --git a/src/handlers/events-query.ts b/src/handlers/events-query.ts index 6d5974d62..179e9ec35 100644 --- a/src/handlers/events-query.ts +++ b/src/handlers/events-query.ts @@ -1,8 +1,9 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; import type { MethodHandler } from '../types/method-handler.js'; -import type { EventsQueryMessage, EventsQueryReply } from '../types/event-types.js'; +import type { EventsQueryMessage, EventsQueryReply } from '../types/events-types.js'; +import { Events } from '../utils/events.js'; import { EventsQuery } from '../interfaces/events-query.js'; import { messageReplyFromError } from '../core/message-reply.js'; import { authenticate, authorizeOwner } from '../core/auth.js'; @@ -31,7 +32,7 @@ export class EventsQueryHandler implements MethodHandler { return messageReplyFromError(e, 401); } - const logFilters = EventsQuery.convertFilters(message.descriptor.filters); + const logFilters = Events.convertFilters(message.descriptor.filters); const { events, cursor } = await this.eventLog.queryEvents(tenant, logFilters, message.descriptor.cursor); return { diff --git a/src/handlers/events-subscribe.ts b/src/handlers/events-subscribe.ts new file mode 100644 index 000000000..8bad408c1 --- /dev/null +++ b/src/handlers/events-subscribe.ts @@ -0,0 +1,68 @@ +import type { DidResolver } from '../did/did-resolver.js'; +import type { MessageSubscriptionHandler } from '../types/message-types.js'; +import type { MethodHandler } from '../types/method-handler.js'; +import type { EventListener, EventStream } from '../types/subscriptions.js'; +import type { EventsSubscribeMessage, EventsSubscribeReply } from '../types/events-types.js'; + +import { Events } from '../utils/events.js'; +import { EventsSubscribe } from '../interfaces/events-subscribe.js'; +import { FilterUtility } from '../utils/filter.js'; +import { Message } from '../core/message.js'; +import { messageReplyFromError } from '../core/message-reply.js'; +import { authenticate, authorizeOwner } from '../core/auth.js'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; + +export class EventsSubscribeHandler implements MethodHandler { + constructor( + private didResolver: DidResolver, + private eventStream?: EventStream + ) {} + + public async handle({ + tenant, + message, + subscriptionHandler + }: { + tenant: string; + message: EventsSubscribeMessage; + subscriptionHandler: MessageSubscriptionHandler; + }): Promise { + if (this.eventStream === undefined) { + return messageReplyFromError(new DwnError( + DwnErrorCode.EventsSubscribeEventStreamUnimplemented, + 'Subscriptions are not supported' + ), 501); + } + + let eventsSubscribe: EventsSubscribe; + try { + eventsSubscribe = await EventsSubscribe.parse(message); + } catch (e) { + return messageReplyFromError(e, 400); + } + + try { + await authenticate(message.authorization, this.didResolver); + await authorizeOwner(tenant, eventsSubscribe); + } catch (error) { + return messageReplyFromError(error, 401); + } + + const { filters } = message.descriptor; + const eventsFilters = Events.convertFilters(filters); + const messageCid = await Message.getCid(message); + + const listener: EventListener = (eventTenant, eventMessage, eventIndexes):void => { + if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) { + subscriptionHandler(eventMessage); + } + }; + + const subscription = await this.eventStream.subscribe(messageCid, listener); + + return { + status: { code: 200, detail: 'OK' }, + subscription, + }; + } +} \ No newline at end of file diff --git a/src/handlers/permissions-grant.ts b/src/handlers/permissions-grant.ts index 04a850076..97e40e9c9 100644 --- a/src/handlers/permissions-grant.ts +++ b/src/handlers/permissions-grant.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; @@ -13,7 +14,12 @@ import { PermissionsGrant } from '../interfaces/permissions-grant.js'; import { removeUndefinedProperties } from '../utils/object.js'; export class PermissionsGrantHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, @@ -41,6 +47,11 @@ export class PermissionsGrantHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } return { diff --git a/src/handlers/permissions-request.ts b/src/handlers/permissions-request.ts index d1a0d762e..8e2efba68 100644 --- a/src/handlers/permissions-request.ts +++ b/src/handlers/permissions-request.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types//event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -12,7 +13,12 @@ import { PermissionsRequest } from '../interfaces/permissions-request.js'; export class PermissionsRequestHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, @@ -45,6 +51,11 @@ export class PermissionsRequestHandler implements MethodHandler { if (existingMessage === undefined) { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } return { diff --git a/src/handlers/permissions-revoke.ts b/src/handlers/permissions-revoke.ts index f7fda04f7..9edefbedc 100644 --- a/src/handlers/permissions-revoke.ts +++ b/src/handlers/permissions-revoke.ts @@ -1,5 +1,6 @@ import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types/message-store.js'; @@ -13,7 +14,12 @@ import { PermissionsRevoke } from '../interfaces/permissions-revoke.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class PermissionsRevokeHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, @@ -89,6 +95,11 @@ export class PermissionsRevokeHandler implements MethodHandler { await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } + // Delete existing revokes which are all newer than the incoming message const removedRevokeCids: string[] = []; for (const existingRevoke of existingRevokesForGrant) { diff --git a/src/handlers/protocols-configure.ts b/src/handlers/protocols-configure.ts index 5873fee58..38e95dff5 100644 --- a/src/handlers/protocols-configure.ts +++ b/src/handlers/protocols-configure.ts @@ -1,6 +1,6 @@ -import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -14,14 +14,17 @@ import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.j export class ProtocolsConfigureHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, message, - dataStream: _dataStream - }: {tenant: string, message: ProtocolsConfigureMessage, dataStream: _Readable.Readable}): Promise { - + }: {tenant: string, message: ProtocolsConfigureMessage }): Promise { let protocolsConfigure: ProtocolsConfigure; try { protocolsConfigure = await ProtocolsConfigure.parse(message); @@ -58,10 +61,15 @@ export class ProtocolsConfigureHandler implements MethodHandler { if (incomingMessageIsNewest) { const indexes = ProtocolsConfigureHandler.constructIndexes(protocolsConfigure); - const messageCid = await Message.getCid(message); await this.messageStore.put(tenant, message, indexes); + const messageCid = await Message.getCid(message); await this.eventLog.append(tenant, messageCid, indexes); + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } + messageReply = { status: { code: 202, detail: 'Accepted' } }; diff --git a/src/handlers/records-delete.ts b/src/handlers/records-delete.ts index 4a9935546..442c716e9 100644 --- a/src/handlers/records-delete.ts +++ b/src/handlers/records-delete.ts @@ -1,8 +1,8 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; -import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; @@ -13,20 +13,24 @@ import { messageReplyFromError } from '../core/message-reply.js'; import { ProtocolAuthorization } from '../core/protocol-authorization.js'; import { RecordsDelete } from '../interfaces/records-delete.js'; import { RecordsWrite } from '../interfaces/records-write.js'; -import { removeUndefinedProperties } from '../utils/object.js'; import { StorageController } from '../store/storage-controller.js'; import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; export class RecordsDeleteHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private dataStore: DataStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, message }: { tenant: string, message: RecordsDeleteMessage}): Promise { - let recordsDelete: RecordsDelete; try { recordsDelete = await RecordsDelete.parse(message); @@ -85,13 +89,17 @@ export class RecordsDeleteHandler implements MethodHandler { return messageReplyFromError(e, 401); } - const recordsWrite = await RecordsWrite.getInitialWrite(existingMessages); - const indexes = RecordsDeleteHandler.constructIndexes(recordsDelete, recordsWrite); - await this.messageStore.put(tenant, message, indexes); - + const initialWrite = await RecordsWrite.getInitialWrite(existingMessages); + const indexes = recordsDelete.constructIndexes(initialWrite); const messageCid = await Message.getCid(message); + await this.messageStore.put(tenant, message, indexes); await this.eventLog.append(tenant, messageCid, indexes); + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } + // delete all existing messages that are not newest, except for the initial write await StorageController.deleteAllOlderMessagesButKeepInitialWrite( tenant, existingMessages, newestMessage, this.messageStore, this.dataStore, this.eventLog @@ -125,30 +133,4 @@ export class RecordsDeleteHandler implements MethodHandler { ); } } - - /** - * Indexed properties needed for MessageStore indexing. - */ - static constructIndexes(recordsDelete: RecordsDelete, recordsWrite: RecordsWriteMessage): KeyValues { - const message = recordsDelete.message; - const descriptor = { ...message.descriptor }; - - // we add the immutable properties from the initial RecordsWrite message in order to use them when querying relevant deletes. - const { protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated } = recordsWrite.descriptor; - - // NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records: - // we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete, - // no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`, - // `isLatestBaseState` for the initial delete would have been toggled to `false` - const indexes: { [key:string]: string | undefined } = { - // isLatestBaseState : "true", // intentionally showing that this index is omitted - protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, - contextId : recordsWrite.contextId, - author : recordsDelete.author, - ...descriptor - }; - removeUndefinedProperties(indexes); - - return indexes as KeyValues; - } -}; \ No newline at end of file +}; diff --git a/src/handlers/records-write.ts b/src/handlers/records-write.ts index 3d356fe8d..65313c875 100644 --- a/src/handlers/records-write.ts +++ b/src/handlers/records-write.ts @@ -1,6 +1,7 @@ import type { DataStore } from '../types/data-store.js'; import type { DidResolver } from '../did/did-resolver.js'; import type { EventLog } from '../types/event-log.js'; +import type { EventStream } from '../types/subscriptions.js'; import type { GenericMessageReply } from '../types/message-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { MethodHandler } from '../types/method-handler.js'; @@ -25,7 +26,13 @@ type HandlerArgs = { tenant: string, message: RecordsWriteMessage, dataStream?: export class RecordsWriteHandler implements MethodHandler { - constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore, private eventLog: EventLog) { } + constructor( + private didResolver: DidResolver, + private messageStore: MessageStore, + private dataStore: DataStore, + private eventLog: EventLog, + private eventStream?: EventStream + ) { } public async handle({ tenant, @@ -120,9 +127,14 @@ export class RecordsWriteHandler implements MethodHandler { } } - const indexes = await recordsWrite.constructRecordsWriteIndexes(isLatestBaseState); + const indexes = await recordsWrite.constructIndexes(isLatestBaseState); await this.messageStore.put(tenant, messageWithOptionalEncodedData, indexes); await this.eventLog.append(tenant, await Message.getCid(message), indexes); + + // only emit if the event stream is set + if (this.eventStream !== undefined) { + this.eventStream.emit(tenant, message, indexes); + } } catch (error) { const e = error as any; if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious || diff --git a/src/index.ts b/src/index.ts index ffbc667ff..c5d5cf728 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,8 @@ export type { DwnConfig } from './dwn.js'; export type { DidMethodResolver, DwnServiceEndpoint, ServiceEndpoint, DidDocument, DidResolutionResult, DidResolutionMetadata, DidDocumentMetadata, VerificationMethod } from './types/did-types.js'; export type { EventLog } from './types/event-log.js'; -export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply } from './types/event-types.js'; +export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply } from './types/events-types.js'; +export type { EventStream, SubscriptionReply } from './types/subscriptions.js'; export type { GenericMessage, GenericMessageReply, MessageSort, Pagination, QueryResultEntry } from './types/message-types.js'; export type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from './types/messages-types.js'; export type { Filter, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions } from './types/query-types.js'; @@ -29,6 +30,7 @@ export { Encoder } from './utils/encoder.js'; export { EventsGet, EventsGetOptions } from './interfaces/events-get.js'; export { Encryption, EncryptionAlgorithm } from './utils/encryption.js'; export { EncryptionInput, KeyEncryptionInput, RecordsWrite, RecordsWriteOptions, CreateFromOptions } from './interfaces/records-write.js'; +export { EventsSubscribe , EventsSubscribeOptions } from './interfaces/events-subscribe.js'; export { executeUnlessAborted } from './utils/abort.js'; export { Jws } from './utils/jws.js'; export { KeyMaterial, PrivateJwk, PublicJwk } from './types/jose-types.js'; @@ -51,10 +53,11 @@ export { Signer } from './types/signer.js'; export { SortDirection } from './types/query-types.js'; export { Time } from './utils/time.js'; -// store interfaces +// concrete implementations of stores and event stream export { DataStoreLevel } from './store/data-store-level.js'; export { EventLogLevel } from './event-log/event-log-level.js'; export { MessageStoreLevel } from './store/message-store-level.js'; +export { EventEmitterStream } from './event-log/event-emitter-stream.js'; // test library exports -export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js'; +export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js'; \ No newline at end of file diff --git a/src/interfaces/events-get.ts b/src/interfaces/events-get.ts index 2ddd50bb3..f168268a2 100644 --- a/src/interfaces/events-get.ts +++ b/src/interfaces/events-get.ts @@ -1,6 +1,6 @@ import type { PaginationCursor } from '../types/query-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsGetDescriptor, EventsGetMessage } from '../types/event-types.js'; +import type { EventsGetDescriptor, EventsGetMessage } from '../types/events-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; diff --git a/src/interfaces/events-query.ts b/src/interfaces/events-query.ts index 7b6e2ad5b..9f7fc39f1 100644 --- a/src/interfaces/events-query.ts +++ b/src/interfaces/events-query.ts @@ -1,20 +1,18 @@ -import type { ProtocolsQueryFilter } from '../types/protocols-types.js'; +import type { PaginationCursor } from '../types/query-types.js'; import type { Signer } from '../types/signer.js'; -import type { EventsMessageFilter, EventsQueryDescriptor, EventsQueryFilter, EventsQueryMessage, EventsRecordsFilter } from '../types/event-types.js'; -import type { Filter, PaginationCursor } from '../types/query-types.js'; +import type { EventsFilter, EventsQueryDescriptor, EventsQueryMessage } from '../types/events-types.js'; import { AbstractMessage } from '../core/abstract-message.js'; -import { FilterUtility } from '../utils/filter.js'; +import { Events } from '../utils/events.js'; import { Message } from '../core/message.js'; -import { ProtocolsQuery } from '../interfaces/protocols-query.js'; -import { Records } from '../utils/records.js'; import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; export type EventsQueryOptions = { signer: Signer; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; messageTimestamp?: string; }; @@ -25,6 +23,15 @@ export class EventsQuery extends AbstractMessage{ Message.validateJsonSchema(message); await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + for (const filter of message.descriptor.filters) { + if ('protocol' in filter && filter.protocol !== undefined) { + validateProtocolUrlNormalized(filter.protocol); + } + if ('schema' in filter && filter.schema !== undefined) { + validateSchemaUrlNormalized(filter.schema); + } + } + return new EventsQuery(message); } @@ -32,7 +39,7 @@ export class EventsQuery extends AbstractMessage{ const descriptor: EventsQueryDescriptor = { interface : DwnInterfaceName.Events, method : DwnMethodName.Query, - filters : this.normalizeFilters(options.filters), + filters : Events.normalizeFilters(options.filters), messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), cursor : options.cursor, }; @@ -46,79 +53,4 @@ export class EventsQuery extends AbstractMessage{ return new EventsQuery(message); } - - private static normalizeFilters(filters: EventsQueryFilter[]): EventsQueryFilter[] { - - const eventsQueryFilters: EventsQueryFilter[] = []; - - // normalize each filter individually by the type of filter it is. - for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(filter); - } else if (this.isRecordsFilter(filter)) { - eventsQueryFilters.push(Records.normalizeFilter(filter)); - } else if (this.isProtocolFilter(filter)) { - const protocolFilter = ProtocolsQuery.normalizeFilter(filter); - eventsQueryFilters.push(protocolFilter!); - } - } - - return eventsQueryFilters; - } - - - /** - * Converts an incoming array of EventsFilter into an array of Filter usable by EventLog. - * - * @param filters An array of EventsFilter - * @returns {Filter[]} an array of generic Filter able to be used when querying. - */ - public static convertFilters(filters: EventsQueryFilter[]): Filter[] { - - const eventsQueryFilters: Filter[] = []; - - // normalize each filter individually by the type of filter it is. - for (const filter of filters) { - if (this.isMessagesFilter(filter)) { - eventsQueryFilters.push(this.convertFilter(filter)); - } else if (this.isRecordsFilter(filter)) { - eventsQueryFilters.push(Records.convertFilter(filter)); - } else if (this.isProtocolFilter(filter)) { - eventsQueryFilters.push(filter); - } - } - - return eventsQueryFilters; - } - - private static convertFilter(filter: EventsMessageFilter): Filter { - const filterCopy = { ...filter } as Filter; - - const { dateUpdated } = filter; - const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined; - if (messageTimestampFilter) { - filterCopy.messageTimestamp = messageTimestampFilter; - delete filterCopy.dateUpdated; - } - return filterCopy as Filter; - } - - private static isMessagesFilter(filter: EventsQueryFilter): filter is EventsMessageFilter { - return 'method' in filter || 'interface' in filter || 'dateUpdated' in filter || 'author' in filter; - } - - private static isRecordsFilter(filter: EventsQueryFilter): filter is EventsRecordsFilter { - return 'dateCreated' in filter || - 'dataFormat' in filter || - 'dataSize' in filter || - 'parentId' in filter || - 'recordId' in filter || - 'schema' in filter || - ('protocolPath' in filter && 'protocol' in filter) || - 'recipient' in filter; - } - - private static isProtocolFilter(filter: EventsQueryFilter): filter is ProtocolsQueryFilter { - return 'protocol' in filter; - } } \ No newline at end of file diff --git a/src/interfaces/events-subscribe.ts b/src/interfaces/events-subscribe.ts new file mode 100644 index 000000000..fe82ace71 --- /dev/null +++ b/src/interfaces/events-subscribe.ts @@ -0,0 +1,64 @@ +import type { Signer } from '../types/signer.js'; +import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/events-types.js'; + +import { AbstractMessage } from '../core/abstract-message.js'; +import { Message } from '../core/message.js'; +import { removeUndefinedProperties } from '../utils/object.js'; +import { Time } from '../utils/time.js'; +import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; +import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js'; + + +export type EventsSubscribeOptions = { + signer: Signer; + messageTimestamp?: string; + filters?: EventsFilter[] +}; + +export class EventsSubscribe extends AbstractMessage { + public static async parse(message: EventsSubscribeMessage): Promise { + Message.validateJsonSchema(message); + await Message.validateSignatureStructure(message.authorization.signature, message.descriptor); + + for (const filter of message.descriptor.filters) { + if ('protocol' in filter && filter.protocol !== undefined) { + validateProtocolUrlNormalized(filter.protocol); + } + if ('schema' in filter && filter.schema !== undefined) { + validateSchemaUrlNormalized(filter.schema); + } + } + + Time.validateTimestamp(message.descriptor.messageTimestamp); + return new EventsSubscribe(message); + } + + /** + * Creates a EventsSubscribe message. + * + * @throws {DwnError} if json schema validation fails. + */ + public static async create( + options: EventsSubscribeOptions + ): Promise { + const currentTime = Time.getCurrentTimestamp(); + + const descriptor: EventsSubscribeDescriptor = { + interface : DwnInterfaceName.Events, + method : DwnMethodName.Subscribe, + filters : options.filters ?? [], + messageTimestamp : options.messageTimestamp ?? currentTime, + }; + + removeUndefinedProperties(descriptor); + + const authorization = await Message.createAuthorization({ + descriptor, + signer: options.signer + }); + + const message: EventsSubscribeMessage = { descriptor, authorization }; + Message.validateJsonSchema(message); + return new EventsSubscribe(message); + } +} diff --git a/src/interfaces/protocols-query.ts b/src/interfaces/protocols-query.ts index 4df419cd8..488001485 100644 --- a/src/interfaces/protocols-query.ts +++ b/src/interfaces/protocols-query.ts @@ -36,11 +36,12 @@ export class ProtocolsQuery extends AbstractMessage { } public static async create(options: ProtocolsQueryOptions): Promise { + const descriptor: ProtocolsQueryDescriptor = { interface : DwnInterfaceName.Protocols, method : DwnMethodName.Query, messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(), - filter : ProtocolsQuery.normalizeFilter(options.filter), + filter : options.filter ? ProtocolsQuery.normalizeFilter(options.filter) : undefined, }; // delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID: @@ -65,11 +66,7 @@ export class ProtocolsQuery extends AbstractMessage { return protocolsQuery; } - static normalizeFilter(filter: ProtocolsQueryFilter | undefined): ProtocolsQueryFilter | undefined { - if (filter === undefined) { - return undefined; - } - + static normalizeFilter(filter: ProtocolsQueryFilter): ProtocolsQueryFilter { return { ...filter, protocol: normalizeProtocolUrl(filter.protocol), diff --git a/src/interfaces/records-delete.ts b/src/interfaces/records-delete.ts index e45c8b7d5..702cbee0f 100644 --- a/src/interfaces/records-delete.ts +++ b/src/interfaces/records-delete.ts @@ -1,4 +1,5 @@ import type { DelegatedGrantMessage } from '../types/delegated-grant-message.js'; +import type { KeyValues } from '../types/query-types.js'; import type { MessageStore } from '../types//message-store.js'; import type { Signer } from '../types/signer.js'; import type { RecordsDeleteDescriptor, RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js'; @@ -7,6 +8,7 @@ import { AbstractMessage } from '../core/abstract-message.js'; import { Message } from '../core/message.js'; import { Records } from '../utils/records.js'; import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js'; +import { removeUndefinedProperties } from '../utils/object.js'; import { Time } from '../utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; @@ -68,7 +70,35 @@ export class RecordsDelete extends AbstractMessage { } /** - * Authorizes the delegate who signed this message. + * Indexed properties needed for MessageStore indexing. + */ + public constructIndexes( + initialWrite: RecordsWriteMessage, + ): KeyValues { + const message = this.message; + const descriptor = { ...message.descriptor }; + + // we add the immutable properties from the initial RecordsWrite message in order to use them when querying relevant deletes. + const { protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated } = initialWrite.descriptor; + + // NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records: + // we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete, + // no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`, + // `isLatestBaseState` for the initial delete would have been toggled to `false` + const indexes: { [key:string]: string | boolean | undefined } = { + // isLatestBaseState : "true", // intentionally showing that this index is omitted + protocol, protocolPath, recipient, schema, parentId, dataFormat, dateCreated, + contextId : initialWrite.contextId, + author : this.author!, + ...descriptor + }; + removeUndefinedProperties(indexes); + + return indexes as KeyValues; + } + + /* + * Authorizes the delegate who signed the message. * @param messageStore Used to check if the grant has been revoked. */ public async authorizeDelegate(recordsWriteToDelete: RecordsWriteMessage, messageStore: MessageStore): Promise { diff --git a/src/interfaces/records-write.ts b/src/interfaces/records-write.ts index c6a28ac63..610352978 100644 --- a/src/interfaces/records-write.ts +++ b/src/interfaces/records-write.ts @@ -689,7 +689,7 @@ export class RecordsWrite implements MessageInterface { } - public async constructRecordsWriteIndexes( + public async constructIndexes( isLatestBaseState: boolean ): Promise { const message = this.message; diff --git a/src/store/storage-controller.ts b/src/store/storage-controller.ts index 14c9932ad..f9a395fa6 100644 --- a/src/store/storage-controller.ts +++ b/src/store/storage-controller.ts @@ -64,7 +64,7 @@ export class StorageController { if (existingMessageIsInitialWrite) { const existingRecordsWrite = await RecordsWrite.parse(message as RecordsWriteMessage); const isLatestBaseState = false; - const indexes = await existingRecordsWrite.constructRecordsWriteIndexes(isLatestBaseState); + const indexes = await existingRecordsWrite.constructIndexes(isLatestBaseState); const writeMessage = message as RecordsQueryReplyEntry; delete writeMessage.encodedData; await messageStore.put(tenant, writeMessage, indexes); diff --git a/src/types/event-types.ts b/src/types/events-types.ts similarity index 57% rename from src/types/event-types.ts rename to src/types/events-types.ts index a0fdb9feb..6856c6972 100644 --- a/src/types/event-types.ts +++ b/src/types/events-types.ts @@ -1,15 +1,19 @@ -import type { ProtocolsQueryFilter } from './protocols-types.js'; -import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js'; +import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSubscription, MessageSubscriptionHandler } from './message-types.js'; import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js'; import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js'; +/** + * filters used when filtering for any type of Message across interfaces + */ export type EventsMessageFilter = { interface?: string; method?: string; dateUpdated?: RangeCriterion; }; -// We only allow filtering for events by immutable properties, the omitted properties could be different per subsequent writes. +/** + * We only allow filtering for events by immutable properties, the omitted properties could be different per subsequent writes. + */ export type EventsRecordsFilter = { recipient?: string; protocol?: string; @@ -23,10 +27,15 @@ export type EventsRecordsFilter = { dateCreated?: RangeCriterion; }; -export type EventsQueryFilter = EventsMessageFilter | EventsRecordsFilter | ProtocolsQueryFilter; + +/** + * A union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe + * TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663 + */ +export type EventsFilter = EventsMessageFilter | EventsRecordsFilter; export type EventsGetDescriptor = { - interface : DwnInterfaceName.Events; + interface: DwnInterfaceName.Events; method: DwnMethodName.Get; cursor?: PaginationCursor; messageTimestamp: string; @@ -42,11 +51,32 @@ export type EventsGetReply = GenericMessageReply & { cursor?: PaginationCursor; }; + +export type EventsSubscribeMessageOptions = { + subscriptionHandler: MessageSubscriptionHandler; +}; + +export type EventsSubscribeMessage = { + authorization: AuthorizationModel; + descriptor: EventsSubscribeDescriptor; +}; + +export type EventsSubscribeReply = GenericMessageReply & { + subscription?: MessageSubscription; +}; + +export type EventsSubscribeDescriptor = { + interface: DwnInterfaceName.Events; + method: DwnMethodName.Subscribe; + messageTimestamp: string; + filters: EventsFilter[]; +}; + export type EventsQueryDescriptor = { interface: DwnInterfaceName.Events; method: DwnMethodName.Query; messageTimestamp: string; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; }; diff --git a/src/types/message-types.ts b/src/types/message-types.ts index c6e0066f3..122083b40 100644 --- a/src/types/message-types.ts +++ b/src/types/message-types.ts @@ -16,6 +16,7 @@ export type GenericMessage = { */ export type MessageOptions = { dataStream?: Readable; + subscriptionHandler?: MessageSubscriptionHandler; }; /** @@ -75,6 +76,13 @@ export type QueryResultEntry = GenericMessage & { encodedData?: string; }; +export type MessageSubscriptionHandler = (message: GenericMessage) => void; + +export interface MessageSubscription { + id: string; + close: () => Promise; +}; + /** * Pagination Options for querying messages. * diff --git a/src/types/method-handler.ts b/src/types/method-handler.ts index 7bc9bb07c..7a3accdfc 100644 --- a/src/types/method-handler.ts +++ b/src/types/method-handler.ts @@ -1,5 +1,5 @@ import type { Readable } from 'readable-stream'; -import type { GenericMessage, GenericMessageReply } from './message-types.js'; +import type { GenericMessage, GenericMessageReply, MessageSubscriptionHandler } from './message-types.js'; /** * Interface that defines a message handler of a specific method. @@ -12,5 +12,6 @@ export interface MethodHandler { tenant: string; message: GenericMessage; dataStream?: Readable + subscriptionHandler?: MessageSubscriptionHandler; }): Promise; } \ No newline at end of file diff --git a/src/types/permissions-grant-descriptor.ts b/src/types/permissions-grant-descriptor.ts index 4218f3f5e..0b552d30f 100644 --- a/src/types/permissions-grant-descriptor.ts +++ b/src/types/permissions-grant-descriptor.ts @@ -53,7 +53,7 @@ export type PermissionScope = { // Method-specific scopes export type RecordsPermissionScope = { interface: DwnInterfaceName.Records; - method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Delete; + method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Subscribe | DwnMethodName.Delete; /** May only be present when `schema` is undefined */ protocol?: string; /** May only be present when `protocol` is defined and `protocolPath` is undefined */ diff --git a/src/types/protocols-types.ts b/src/types/protocols-types.ts index 7ddfccd25..00be73bb8 100644 --- a/src/types/protocols-types.ts +++ b/src/types/protocols-types.ts @@ -40,6 +40,7 @@ export enum ProtocolAction { Delete = 'delete', Query = 'query', Read = 'read', + Subscribe = 'subscribe', Update = 'update', Write = 'write' } @@ -89,8 +90,8 @@ export type ProtocolActionRule = { /** * Action that the actor can perform. - * May be 'query' | 'read' | 'write' | 'update' | 'delete'. - * 'query' is only supported for `role` rules. + * May be 'query' | 'read' | 'write' | 'update' | 'delete' | 'subscribe'. + * 'query' and 'subscribe' are only supported for `role` rules. */ can: string; }; diff --git a/src/types/records-types.ts b/src/types/records-types.ts index e47d09d0f..18ea8211b 100644 --- a/src/types/records-types.ts +++ b/src/types/records-types.ts @@ -178,4 +178,4 @@ export type RecordsDeleteDescriptor = { method: DwnMethodName.Delete; recordId: string; messageTimestamp: string; -}; \ No newline at end of file +}; diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts new file mode 100644 index 000000000..72ea93a39 --- /dev/null +++ b/src/types/subscriptions.ts @@ -0,0 +1,24 @@ +import type { GenericMessageReply } from '../types/message-types.js'; +import type { KeyValues } from './query-types.js'; +import type { GenericMessage, MessageSubscription } from './message-types.js'; + +export type EventListener = (tenant: string, message: GenericMessage, indexes: KeyValues) => void; + +/** + * The EventStream interface implements a pub/sub system based on Message filters. + */ +export interface EventStream { + subscribe(id: string, listener: EventListener): Promise; + emit(tenant: string, message: GenericMessage, indexes: KeyValues): void; + open(): Promise; + close(): Promise; +} + +export interface EventSubscription { + id: string; + close: () => Promise; +} + +export type SubscriptionReply = GenericMessageReply & { + subscription?: MessageSubscription; +}; \ No newline at end of file diff --git a/src/utils/events.ts b/src/utils/events.ts new file mode 100644 index 000000000..f66c56dc0 --- /dev/null +++ b/src/utils/events.ts @@ -0,0 +1,89 @@ +import type { Filter } from '../types/query-types.js'; +import type { EventsFilter, EventsMessageFilter, EventsRecordsFilter } from '../types/events-types.js'; + +import { FilterUtility } from '../utils/filter.js'; +import { Records } from '../utils/records.js'; +import { isEmptyObject, removeUndefinedProperties } from './object.js'; + + +/** + * Class containing Events related utility methods. + */ +export class Events { + public static normalizeFilters(filters: EventsFilter[]): EventsFilter[] { + + const eventsQueryFilters: EventsFilter[] = []; + + // normalize each filter individually by the type of filter it is. + for (const filter of filters) { + let eventsFilter: EventsFilter; + if (this.isRecordsFilter(filter)) { + eventsFilter = Records.normalizeFilter(filter); + } else { + // no normalization needed + eventsFilter = filter; + } + + // remove any empty filter properties and do not add if empty + removeUndefinedProperties(eventsFilter); + if (!isEmptyObject(eventsFilter)) { + eventsQueryFilters.push(eventsFilter); + } + } + + + return eventsQueryFilters; + } + + /** + * Converts an incoming array of EventsFilter into an array of Filter usable by EventLog. + * + * @param filters An array of EventsFilter + * @returns {Filter[]} an array of generic Filter able to be used when querying. + */ + public static convertFilters(filters: EventsFilter[]): Filter[] { + + const eventsQueryFilters: Filter[] = []; + + // convert each filter individually by the specific type of filter it is + // we must check for the type of filter in a specific order to make a reductive decision as to which filters need converting + // first we check for `EventsRecordsFilter` fields for conversion + // otherwise it is `EventsMessageFilter` fields for conversion + for (const filter of filters) { + if (this.isRecordsFilter(filter)) { + eventsQueryFilters.push(Records.convertFilter(filter)); + } else { + eventsQueryFilters.push(this.convertFilter(filter)); + } + } + + return eventsQueryFilters; + } + + private static convertFilter(filter: EventsMessageFilter): Filter { + const filterCopy = { ...filter } as Filter; + + const { dateUpdated } = filter; + const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined; + if (messageTimestampFilter) { + filterCopy.messageTimestamp = messageTimestampFilter; + delete filterCopy.dateUpdated; + } + return filterCopy as Filter; + } + + // we deliberately do not check for `dateUpdated` in this filter. + // if it were the only property that matched, it could be handled by `EventMessageFilter` + private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter { + return 'author' in filter || + 'dateCreated' in filter || + 'dataFormat' in filter || + 'dataSize' in filter || + 'parentId' in filter || + 'recordId' in filter || + 'schema' in filter || + 'protocol' in filter || + 'protocolPath' in filter || + 'recipient' in filter; + } +} \ No newline at end of file diff --git a/tests/dwn.spec.ts b/tests/dwn.spec.ts index 797e67fb6..3ad69d3bc 100644 --- a/tests/dwn.spec.ts +++ b/tests/dwn.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../src/types/subscriptions.js'; import type { ActiveTenantCheckResult, EventsGetReply, TenantGate } from '../src/index.js'; import type { DataStore, EventLog, MessageStore } from '../src/index.js'; @@ -10,6 +11,7 @@ import { Dwn } from '../src/dwn.js'; import { Message } from '../src/core/message.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from './utils/test-data-generator.js'; +import { TestEventStream } from './test-event-stream.js'; import { TestStores } from './test-stores.js'; chai.use(chaiAsPromised); @@ -19,6 +21,7 @@ export function testDwnClass(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -29,7 +32,9 @@ export function testDwnClass(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -125,12 +130,14 @@ export function testDwnClass(): void { const messageStoreStub = stubInterface(); const dataStoreStub = stubInterface(); const eventLogStub = stubInterface(); + const eventStreamStub = stubInterface(); const dwnWithConfig = await Dwn.create({ tenantGate : blockAllTenantGate, messageStore : messageStoreStub, dataStore : dataStoreStub, - eventLog : eventLogStub + eventLog : eventLogStub, + eventStream : eventStreamStub }); const alice = await DidKeyResolver.generate(); diff --git a/tests/event-log/event-emitter-stream.spec.ts b/tests/event-log/event-emitter-stream.spec.ts new file mode 100644 index 000000000..947729cc8 --- /dev/null +++ b/tests/event-log/event-emitter-stream.spec.ts @@ -0,0 +1,59 @@ +import type { MessageStore } from '../../src/index.js'; + +import { EventEmitterStream } from '../../src/event-log/event-emitter-stream.js'; +import { TestStores } from '../test-stores.js'; + +import sinon from 'sinon'; + +import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; + +chai.use(chaiAsPromised); + +describe('EventEmitterStream', () => { + // saving the original `console.error` function to re-assign after tests complete + const originalConsoleErrorFunction = console.error; + let eventStream: EventEmitterStream; + let messageStore: MessageStore; + + before(() => { + ({ messageStore } = TestStores.get()); + + // do not print the console error statements from the emitter error + console.error = (_):void => { }; + }); + + beforeEach(async () => { + messageStore.clear(); + }); + + after(async () => { + console.error = originalConsoleErrorFunction; + // Clean up after each test by closing and clearing the event stream + await messageStore.close(); + await eventStream.close(); + }); + + it('should remove listeners when `close` method is used', async () => { + eventStream = new EventEmitterStream(); + const emitter = eventStream['eventEmitter']; + + // count the `events` listeners, which represents all listeners + expect(emitter.listenerCount('events')).to.equal(0); + + const sub = await eventStream.subscribe('id', () => {}); + expect(emitter.listenerCount('events')).to.equal(1); + + // close the subscription, which should remove the listener + await sub.close(); + expect(emitter.listenerCount('events')).to.equal(0); + }); + + it('logs message when the emitter experiences an error', async () => { + const eventErrorSpy = sinon.spy(EventEmitterStream.prototype as any, 'eventError'); + eventStream = new EventEmitterStream(); + const emitter = eventStream['eventEmitter']; + emitter.emit('error', new Error('random error')); + expect(eventErrorSpy.callCount).to.equal(1); + }); +}); diff --git a/tests/event-log/event-log-level.spec.ts b/tests/event-log/event-log-level.spec.ts index 8051f5025..dcdbcef67 100644 --- a/tests/event-log/event-log-level.spec.ts +++ b/tests/event-log/event-log-level.spec.ts @@ -29,7 +29,7 @@ describe('EventLogLevel Tests', () => { it('deletes all index related data', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); const indexLevelDeleteSpy = sinon.spy(eventLog.index, 'delete'); diff --git a/tests/event-log/event-log.spec.ts b/tests/event-log/event-log.spec.ts index da759cfeb..dcb28a2fa 100644 --- a/tests/event-log/event-log.spec.ts +++ b/tests/event-log/event-log.spec.ts @@ -30,12 +30,12 @@ export function testEventLog(): void { it('separates events by tenant', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); - const message1Index = await recordsWrite.constructRecordsWriteIndexes(true); + const message1Index = await recordsWrite.constructIndexes(true); const messageCid = await Message.getCid(message); await eventLog.append(author.did, messageCid, message1Index); const { author: author2, message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite(); - const message2Index = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Index = await recordsWrite2.constructIndexes(true); const messageCid2 = await Message.getCid(message2); await eventLog.append(author2.did, messageCid2, message2Index); @@ -53,7 +53,7 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const messageIndex = await recordsWrite.constructRecordsWriteIndexes(true); + const messageIndex = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, messageIndex); expectedMessages.push(messageCid); @@ -61,7 +61,7 @@ export function testEventLog(): void { for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); @@ -81,14 +81,14 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const messageIndex = await recordsWrite.constructRecordsWriteIndexes(true); + const messageIndex = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, messageIndex); expectedMessages.push(messageCid); for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); @@ -108,7 +108,8 @@ export function testEventLog(): void { // create an initial record to and, issue a getEvents and grab the cursor const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); + await eventLog.append(author.did, messageCid, index); const { events: cursorEvents, cursor } = await eventLog.getEvents(author.did); expect(cursorEvents.length).to.equal(1); @@ -120,7 +121,8 @@ export function testEventLog(): void { for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); + await eventLog.append(author.did, messageCid, index); expectedMessages.push(messageCid); } @@ -138,7 +140,7 @@ export function testEventLog(): void { it('finds and deletes events that whose values match the cids provided', async () => { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); @@ -146,7 +148,7 @@ export function testEventLog(): void { for (let i = 0; i < 9; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); if (i % 2 === 0) { @@ -164,7 +166,7 @@ export function testEventLog(): void { const cids: string[] = []; const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite(); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); cids.push(messageCid); @@ -172,7 +174,7 @@ export function testEventLog(): void { for (let i = 0; i < 3; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author }); const messageCid = await Message.getCid(message); - const index = await recordsWrite.constructRecordsWriteIndexes(true); + const index = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, index); cids.push(messageCid); @@ -192,7 +194,7 @@ export function testEventLog(): void { const { author, message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -200,7 +202,7 @@ export function testEventLog(): void { for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -210,13 +212,13 @@ export function testEventLog(): void { // not inserted into expected events. const { message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite({ author }); const message2Cid = await Message.getCid(message2); - const message2Indexes = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Indexes = await recordsWrite2.constructIndexes(true); await eventLog.append(author.did, message2Cid, message2Indexes); for (let i = 0; i < 5; i += 1) { const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); expectedMessages.push(messageCid); @@ -236,26 +238,26 @@ export function testEventLog(): void { // message 1 schema1 const { message, recordsWrite } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const messageCid = await Message.getCid(message); - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); await eventLog.append(author.did, messageCid, indexes); // message 2 schema1 const { message: message2, recordsWrite: recordsWrite2 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message2Cid = await Message.getCid(message2); - const message2Indexes = await recordsWrite2.constructRecordsWriteIndexes(true); + const message2Indexes = await recordsWrite2.constructIndexes(true); await eventLog.append(author.did, message2Cid, message2Indexes); // message 3 schema1 const { message: message3, recordsWrite: recordsWrite3 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message3Cid = await Message.getCid(message3); - const message3Indexes = await recordsWrite3.constructRecordsWriteIndexes(true); + const message3Indexes = await recordsWrite3.constructIndexes(true); await eventLog.append(author.did, message3Cid, message3Indexes); // insert a record that will not show up in the filtered query. // not inserted into expected events because it's not a part of the schema. const { message: nonSchemaMessage1, recordsWrite: nonSchemaMessage1Write } = await TestDataGenerator.generateRecordsWrite({ author }); const nonSchemaMessage1Cid = await Message.getCid(nonSchemaMessage1); - const nonSchemaMessage1Indexes = await nonSchemaMessage1Write.constructRecordsWriteIndexes(true); + const nonSchemaMessage1Indexes = await nonSchemaMessage1Write.constructIndexes(true); await eventLog.append(author.did, nonSchemaMessage1Cid, nonSchemaMessage1Indexes); // make initial query @@ -268,13 +270,13 @@ export function testEventLog(): void { // add an additional message to schema 1 const { message: message4, recordsWrite: recordsWrite4 } = await TestDataGenerator.generateRecordsWrite({ author, schema: 'schema1' }); const message4Cid = await Message.getCid(message4); - const message4Indexes = await recordsWrite4.constructRecordsWriteIndexes(true); + const message4Indexes = await recordsWrite4.constructIndexes(true); await eventLog.append(author.did, message4Cid, message4Indexes); // insert another non schema record const { message: nonSchemaMessage2, recordsWrite: nonSchemaMessage2Write } = await TestDataGenerator.generateRecordsWrite({ author }); const nonSchemaMessage2Cid = await Message.getCid(nonSchemaMessage2); - const nonSchemaMessage2Indexes = await nonSchemaMessage2Write.constructRecordsWriteIndexes(true); + const nonSchemaMessage2Indexes = await nonSchemaMessage2Write.constructIndexes(true); await eventLog.append(author.did, nonSchemaMessage2Cid, nonSchemaMessage2Indexes); ({ events } = await eventLog.queryEvents(author.did, [{ schema: normalizeSchemaUrl('schema1') }], cursor)); diff --git a/tests/event-log/event-stream.spec.ts b/tests/event-log/event-stream.spec.ts new file mode 100644 index 000000000..1ebc81857 --- /dev/null +++ b/tests/event-log/event-stream.spec.ts @@ -0,0 +1,108 @@ +import type { KeyValues } from '../../src/types/query-types.js'; +import type { EventStream, GenericMessage } from '../../src/index.js'; + +import { TestEventStream } from '../test-event-stream.js'; +import { Message, TestDataGenerator, Time } from '../../src/index.js'; + +import chaiAsPromised from 'chai-as-promised'; +import chai, { expect } from 'chai'; + +chai.use(chaiAsPromised); + +describe('EventStream', () => { + // saving the original `console.error` function to re-assign after tests complete + const originalConsoleErrorFunction = console.error; + let eventStream: EventStream; + + before(async () => { + eventStream = TestEventStream.get(); + await eventStream.open(); + + // do not print the console error statements from the emitter error + console.error = (_):void => { }; + }); + + after(async () => { + console.error = originalConsoleErrorFunction; + // Clean up after each test by closing and clearing the event stream + await eventStream.close(); + }); + + it('emits all messages to each subscriptions', async () => { + const messageCids1: string[] = []; + const handler1 = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids1.push(messageCid); + }; + + const messageCids2: string[] = []; + const handler2 = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids2.push(messageCid); + }; + + const subscription1 = await eventStream.subscribe('sub-1', handler1); + const subscription2 = await eventStream.subscribe('sub-2', handler2); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + const message1Cid = await Message.getCid(message1.message); + eventStream.emit('did:alice', message1.message, {}); + const message2 = await TestDataGenerator.generateRecordsWrite({}); + const message2Cid = await Message.getCid(message2.message); + eventStream.emit('did:alice', message2.message, {}); + const message3 = await TestDataGenerator.generateRecordsWrite({}); + const message3Cid = await Message.getCid(message3.message); + eventStream.emit('did:alice', message3.message, {}); + + await subscription1.close(); + await subscription2.close(); + + await Time.minimalSleep(); + + expect(messageCids1).to.have.members([ message1Cid, message2Cid, message3Cid ]); + expect(messageCids2).to.have.members([ message1Cid, message2Cid, message3Cid ]); + }); + + it('does not emit messages if subscription is closed', async () => { + const messageCids: string[] = []; + const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const subscription = await eventStream.subscribe('sub-1', handler); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + const message1Cid = await Message.getCid(message1.message); + eventStream.emit('did:alice', message1.message, {}); + await subscription.close(); + + const message2 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message2.message, {}); + + await Time.minimalSleep(); + + expect(messageCids).to.have.members([ message1Cid ]); + }); + + it('does not emit messages if event stream is closed', async () => { + const messageCids: string[] = []; + const handler = async (_tenant: string, message: GenericMessage, _indexes: KeyValues): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + const subscription = await eventStream.subscribe('sub-1', handler); + + // close eventStream + await eventStream.close(); + + const message1 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message1.message, {}); + const message2 = await TestDataGenerator.generateRecordsWrite({}); + eventStream.emit('did:alice', message2.message, {}); + + await subscription.close(); + + await Time.minimalSleep(); + expect(messageCids).to.have.length(0); + }); +}); diff --git a/tests/handlers/events-get.spec.ts b/tests/handlers/events-get.spec.ts index 57ccb7744..eef10ce8b 100644 --- a/tests/handlers/events-get.spec.ts +++ b/tests/handlers/events-get.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -11,10 +12,11 @@ import { TestDataGenerator } from '../utils/test-data-generator.js'; import { DidKeyResolver, DidResolver, - Dwn + Dwn, } from '../../src/index.js'; import { Message } from '../../src/core/message.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; export function testEventsGetHandler(): void { @@ -23,6 +25,7 @@ export function testEventsGetHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -34,8 +37,9 @@ export function testEventsGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/events-query.spec.ts b/tests/handlers/events-query.spec.ts index a8a9d5ff3..e1a967d3e 100644 --- a/tests/handlers/events-query.spec.ts +++ b/tests/handlers/events-query.spec.ts @@ -1,12 +1,14 @@ import type { DataStore, EventLog, + EventStream, MessageStore } from '../../src/index.js'; import { EventsQueryHandler } from '../../src/handlers/events-query.js'; import { expect } from 'chai'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { DidKeyResolver, @@ -21,6 +23,7 @@ export function testEventsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -32,8 +35,9 @@ export function testEventsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/events-subscribe.spec.ts b/tests/handlers/events-subscribe.spec.ts new file mode 100644 index 000000000..9f52fc1bd --- /dev/null +++ b/tests/handlers/events-subscribe.spec.ts @@ -0,0 +1,193 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; +import type { DataStore, EventLog, GenericMessage, MessageStore } from '../../src/index.js'; + +import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; +import { DidResolver } from '../../src/did/did-resolver.js'; +import { Dwn } from '../../src/dwn.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; +import { Jws } from '../../src/utils/jws.js'; +import { Message } from '../../src/core/message.js'; +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; +import { TestStores } from '../test-stores.js'; + +import sinon from 'sinon'; +import chai, { expect } from 'chai'; + +import chaiAsPromised from 'chai-as-promised'; +import { EventsSubscribeHandler } from '../../src/handlers/events-subscribe.js'; +chai.use(chaiAsPromised); + +export function testEventsSubscribeHandler(): void { + describe('EventsSubscribe.handle()', () => { + + describe('EventStream disabled',() => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + + dwn = await Dwn.create({ + didResolver, + messageStore, + dataStore, + eventLog, + }); + + }); + + + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes + + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + it('should respond with a 501 if subscriptions are not supported', async () => { + await dwn.close(); // close the original dwn instance + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); // leave out eventStream + + const alice = await DidKeyResolver.generate(); + // attempt to subscribe + const { message } = await EventsSubscribe.create({ signer: Jws.createSigner(alice) }); + const subscriptionMessageReply = await dwn.processMessage(alice.did, message, { subscriptionHandler: (_) => {} }); + expect(subscriptionMessageReply.status.code).to.equal(501, subscriptionMessageReply.status.detail); + expect(subscriptionMessageReply.status.detail).to.include(DwnErrorCode.EventsSubscribeEventStreamUnimplemented); + }); + + }); + + describe('EventStream enabled', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ + didResolver, + messageStore, + dataStore, + eventLog, + eventStream, + }); + + }); + + beforeEach(async () => { + sinon.restore(); // wipe all previous stubs/spies/mocks/fakes + + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + it('returns a 400 if message is invalid', async () => { + const alice = await DidKeyResolver.generate(); + const { message } = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + + // add an invalid property to the descriptor + (message['descriptor'] as any)['invalid'] = 'invalid'; + + const eventsSubscribeHandler = new EventsSubscribeHandler(didResolver, eventStream); + + const reply = await eventsSubscribeHandler.handle({ tenant: alice.did, message, subscriptionHandler: (_) => {} }); + expect(reply.status.code).to.equal(400); + }); + + + it('should allow tenant to subscribe their own event stream', async () => { + const alice = await DidKeyResolver.generate(); + + // set up a promise to read later that captures the emitted messageCid + let handler; + const messageSubscriptionPromise: Promise = new Promise((resolve) => { + handler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + resolve(messageCid); + }; + }); + + // testing EventsSubscribe + const eventsSubscribe = await EventsSubscribe.create({ + signer: Jws.createSigner(alice), + }); + const subscriptionReply = await dwn.processMessage(alice.did, eventsSubscribe.message, { subscriptionHandler: handler }); + expect(subscriptionReply.status.code).to.equal(200, subscriptionReply.status.detail); + expect(subscriptionReply.subscription).to.not.be.undefined; + + const messageWrite = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const writeReply = await dwn.processMessage(alice.did, messageWrite.message, { dataStream: messageWrite.dataStream }); + expect(writeReply.status.code).to.equal(202); + const messageCid = await Message.getCid(messageWrite.message); + + // control: ensure that the event exists + const { events } = await eventLog.getEvents(alice.did); + expect(events.length).to.equal(1); + expect(events[0]).to.equal(messageCid); + + // await the event + await expect(messageSubscriptionPromise).to.eventually.equal(messageCid); + }); + + it('should not allow non-tenant to subscribe to an event stream they are not authorized for', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + // test anonymous request + const anonymousSubscription = await TestDataGenerator.generateEventsSubscribe(); + delete (anonymousSubscription.message as any).authorization; + + const anonymousReply = await dwn.processMessage(alice.did, anonymousSubscription.message); + expect(anonymousReply.status.code).to.equal(400); + expect(anonymousReply.status.detail).to.include(`EventsSubscribe: must have required property 'authorization'`); + expect(anonymousReply.subscription).to.be.undefined; + + // testing EventsSubscribe + const eventsSubscribe = await EventsSubscribe.create({ + signer: Jws.createSigner(bob), + }); + + const subscriptionReply = await dwn.processMessage(alice.did, eventsSubscribe.message); + expect(subscriptionReply.status.code).to.equal(401); + expect(subscriptionReply.subscription).to.be.undefined; + }); + }); + }); +} \ No newline at end of file diff --git a/tests/handlers/messages-get.spec.ts b/tests/handlers/messages-get.spec.ts index 4f87b1e6d..7c98ea1a3 100644 --- a/tests/handlers/messages-get.spec.ts +++ b/tests/handlers/messages-get.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -10,6 +11,7 @@ import { Message } from '../../src/core/message.js'; import { MessagesGetHandler } from '../../src/handlers/messages-get.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { DidKeyResolver, DidResolver, Dwn, DwnConstant } from '../../src/index.js'; @@ -22,6 +24,7 @@ export function testMessagesGetHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests // so that different test suites can reuse the same backend store for testing @@ -32,8 +35,9 @@ export function testMessagesGetHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/permissions-grant.spec.ts b/tests/handlers/permissions-grant.spec.ts index 90d148f2d..b2c9c7328 100644 --- a/tests/handlers/permissions-grant.spec.ts +++ b/tests/handlers/permissions-grant.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -16,6 +17,7 @@ import { Message } from '../../src/core/message.js'; import { PermissionsGrant } from '../../src/interfaces/permissions-grant.js'; import { PermissionsGrantHandler } from '../../src/handlers/permissions-grant.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/enums/dwn-interface-method.js'; @@ -26,6 +28,7 @@ export function testPermissionsGrantHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -39,8 +42,9 @@ export function testPermissionsGrantHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -134,7 +138,7 @@ export function testPermissionsGrantHandler(): void { const alice = await DidKeyResolver.generate(); const { message } = await TestDataGenerator.generatePermissionsGrant(); - const permissionsRequestHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog); + const permissionsRequestHandler = new PermissionsGrantHandler(didResolver, messageStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(PermissionsGrant, 'parse').throws('anyError'); diff --git a/tests/handlers/permissions-request.spec.ts b/tests/handlers/permissions-request.spec.ts index eeb68cb5c..4dbf59ba2 100644 --- a/tests/handlers/permissions-request.spec.ts +++ b/tests/handlers/permissions-request.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -14,6 +15,7 @@ import { Message } from '../../src/core/message.js'; import { PermissionsRequest } from '../../src/interfaces/permissions-request.js'; import { PermissionsRequestHandler } from '../../src/handlers/permissions-request.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { DwnInterfaceName, DwnMethodName } from '../../src/index.js'; @@ -23,6 +25,7 @@ export function testPermissionsRequestHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -36,8 +39,9 @@ export function testPermissionsRequestHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -94,7 +98,7 @@ export function testPermissionsRequestHandler(): void { const alice = await DidKeyResolver.generate(); const { message } = await TestDataGenerator.generatePermissionsRequest(); - const permissionsRequestHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog); + const permissionsRequestHandler = new PermissionsRequestHandler(didResolver, messageStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(PermissionsRequest, 'parse').throws('anyError'); diff --git a/tests/handlers/permissions-revoke.spec.ts b/tests/handlers/permissions-revoke.spec.ts index 8643395b9..73be4efe1 100644 --- a/tests/handlers/permissions-revoke.spec.ts +++ b/tests/handlers/permissions-revoke.spec.ts @@ -1,23 +1,25 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { DataStoreLevel } from '../../src/store/data-store-level.js'; +import type { DataStore, EventLog, EventStream, MessageStore } from '../../src/index.js'; + import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DidResolver } from '../../src/did/did-resolver.js'; import { Dwn } from '../../src/dwn.js'; import { DwnErrorCode } from '../../src/core/dwn-error.js'; -import { EventLogLevel } from '../../src/event-log/event-log-level.js'; import { Message } from '../../src/core/message.js'; -import { MessageStoreLevel } from '../../src/store/message-store-level.js'; import { PermissionsRevoke } from '../../src/interfaces/permissions-revoke.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; +import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; describe('PermissionsRevokeHandler.handle()', () => { let didResolver: DidResolver; - let messageStore: MessageStoreLevel; - let dataStore: DataStoreLevel; - let eventLog: EventLogLevel; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -26,20 +28,14 @@ describe('PermissionsRevokeHandler.handle()', () => { // important to follow this pattern to initialize and clean the message and data store in tests // so that different suites can reuse the same block store and index location for testing - messageStore = new MessageStoreLevel({ - blockstoreLocation : 'TEST-MESSAGESTORE', - indexLocation : 'TEST-INDEX' - }); + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; - dataStore = new DataStoreLevel({ - blockstoreLocation: 'TEST-DATASTORE' - }); - - eventLog = new EventLogLevel({ - location: 'TEST-EVENTLOG' - }); + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/protocols-configure.spec.ts b/tests/handlers/protocols-configure.spec.ts index 2d0c2a870..7d56d2dc8 100644 --- a/tests/handlers/protocols-configure.spec.ts +++ b/tests/handlers/protocols-configure.spec.ts @@ -1,3 +1,5 @@ + +import type { EventStream } from '../../src/types/subscriptions.js'; import type { GenerateProtocolsConfigureOutput } from '../utils/test-data-generator.js'; import type { DataStore, @@ -17,6 +19,7 @@ import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { lexicographicalCompare } from '../../src/utils/string.js'; import { Message } from '../../src/core/message.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; @@ -31,6 +34,7 @@ export function testProtocolsConfigureHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -45,7 +49,9 @@ export function testProtocolsConfigureHandler(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/protocols-query.spec.ts b/tests/handlers/protocols-query.spec.ts index d46aafde6..f654fa441 100644 --- a/tests/handlers/protocols-query.spec.ts +++ b/tests/handlers/protocols-query.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -13,6 +14,7 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { GeneralJwsBuilder } from '../../src/jose/jws/general/builder.js'; import { Message } from '../../src/core/message.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; @@ -26,6 +28,7 @@ export function testProtocolsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -39,8 +42,9 @@ export function testProtocolsQueryHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/records-delete.spec.ts b/tests/handlers/records-delete.spec.ts index 70f31f8bd..d5d192310 100644 --- a/tests/handlers/records-delete.spec.ts +++ b/tests/handlers/records-delete.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, @@ -17,13 +18,14 @@ import threadRoleProtocolDefinition from '../vectors/protocol-definitions/thread import { ArrayUtility } from '../../src/utils/array.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; -import { DwnErrorCode } from '../../src/index.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { DwnMethodName } from '../../src/enums/dwn-interface-method.js'; import { Message } from '../../src/core/message.js'; import { normalizeSchemaUrl } from '../../src/utils/url.js'; import { RecordsDeleteHandler } from '../../src/handlers/records-delete.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; @@ -37,6 +39,7 @@ export function testRecordsDeleteHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -50,8 +53,9 @@ export function testRecordsDeleteHandler(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -760,7 +764,7 @@ export function testRecordsDeleteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsDeleteHandler.handle({ tenant, message }); expect(reply.status.code).to.equal(401); }); @@ -773,7 +777,7 @@ export function testRecordsDeleteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsDeleteHandler = new RecordsDeleteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); // stub the `parse()` function to throw an error sinon.stub(RecordsDelete, 'parse').throws('anyError'); diff --git a/tests/handlers/records-query.spec.ts b/tests/handlers/records-query.spec.ts index d45757f2a..0be0ead9d 100644 --- a/tests/handlers/records-query.spec.ts +++ b/tests/handlers/records-query.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore } from '../../src/index.js'; import type { GenericMessage, RecordsWriteMessage } from '../../src/index.js'; import type { RecordsQueryReply, RecordsQueryReplyEntry, RecordsWriteDescriptor } from '../../src/types/records-types.js'; @@ -14,7 +15,7 @@ import { ArrayUtility } from '../../src/utils/array.js'; import { DateSort } from '../../src/types/records-types.js'; import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { DwnConstant } from '../../src/core/dwn-constant.js'; -import { DwnErrorCode } from '../../src/index.js'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Encoder } from '../../src/utils/encoder.js'; import { Jws } from '../../src/utils/jws.js'; import { Message } from '../../src/core/message.js'; @@ -23,6 +24,7 @@ import { RecordsQueryHandler } from '../../src/handlers/records-query.js'; import { RecordsWriteHandler } from '../../src/handlers/records-write.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { DidResolver, Dwn, RecordsWrite, Time } from '../../src/index.js'; @@ -36,6 +38,7 @@ export function testRecordsQueryHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -48,7 +51,9 @@ export function testRecordsQueryHandler(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -1487,29 +1492,29 @@ export function testRecordsQueryHandler(): void { ); // directly inserting data to datastore so that we don't have to setup to grant Bob permission to write to Alice's DWN - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); - const additionalIndexes1 = await record1Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes1 = await record1Data.recordsWrite.constructIndexes(true); record1Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record1Data.message, record1Data.dataBytes!); await messageStore.put(alice.did, record1Data.message, additionalIndexes1); await eventLog.append(alice.did, await Message.getCid(record1Data.message), additionalIndexes1); - const additionalIndexes2 = await record2Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes2 = await record2Data.recordsWrite.constructIndexes(true); record2Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record2Data.message,record2Data.dataBytes!); await messageStore.put(alice.did, record2Data.message, additionalIndexes2); await eventLog.append(alice.did, await Message.getCid(record2Data.message), additionalIndexes1); - const additionalIndexes3 = await record3Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes3 = await record3Data.recordsWrite.constructIndexes(true); record3Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record3Data.message, record3Data.dataBytes!); await messageStore.put(alice.did, record3Data.message, additionalIndexes3); await eventLog.append(alice.did, await Message.getCid(record3Data.message), additionalIndexes1); - const additionalIndexes4 = await record4Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes4 = await record4Data.recordsWrite.constructIndexes(true); record4Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record4Data.message, record4Data.dataBytes!); await messageStore.put(alice.did, record4Data.message, additionalIndexes4); await eventLog.append(alice.did, await Message.getCid(record4Data.message), additionalIndexes1); - const additionalIndexes5 = await record5Data.recordsWrite.constructRecordsWriteIndexes(true); + const additionalIndexes5 = await record5Data.recordsWrite.constructIndexes(true); record5Data.message = await recordsWriteHandler.cloneAndAddEncodedData(record5Data.message, record5Data.dataBytes!); await messageStore.put(alice.did, record5Data.message, additionalIndexes5); await eventLog.append(alice.did, await Message.getCid(record5Data.message), additionalIndexes1); @@ -1612,11 +1617,11 @@ export function testRecordsQueryHandler(): void { ...aliceMessagesForBobPromise, ]; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const messages: GenericMessage[] = []; for await (const { recordsWrite, message, dataBytes } of messagePromises) { - const indexes = await recordsWrite.constructRecordsWriteIndexes(true); + const indexes = await recordsWrite.constructIndexes(true); const processedMessage = await recordsWriteHandler.cloneAndAddEncodedData(message, dataBytes!); await messageStore.put(alice.did, processedMessage, indexes); await eventLog.append(alice.did, await Message.getCid(processedMessage), indexes); diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index c14db8c7a..5ecb04137 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -1,5 +1,6 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; import type { EncryptionInput } from '../../src/interfaces/records-write.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage } from '../../src/index.js'; import { DwnConstant, Message } from '../../src/index.js'; @@ -27,6 +28,7 @@ import { KeyDerivationScheme } from '../../src/utils/hd-key.js'; import { RecordsReadHandler } from '../../src/handlers/records-read.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; @@ -41,6 +43,7 @@ export function testRecordsReadHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -55,7 +58,9 @@ export function testRecordsReadHandler(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index b3f3b466c..b5d7509a6 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -1,4 +1,5 @@ import type { EncryptionInput } from '../../src/interfaces/records-write.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { GenerateFromRecordsWriteOut } from '../utils/test-data-generator.js'; import type { ProtocolDefinition } from '../../src/types/protocols-types.js'; import type { RecordsQueryReplyEntry } from '../../src/types/records-types.js'; @@ -38,6 +39,7 @@ import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { RecordsWriteHandler } from '../../src/handlers/records-write.js'; import { stubInterface } from 'ts-sinon'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; import { Time } from '../../src/utils/time.js'; @@ -53,6 +55,7 @@ export function testRecordsWriteHandler(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; describe('functional tests', () => { @@ -67,7 +70,9 @@ export function testRecordsWriteHandler(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -3077,7 +3082,7 @@ export function testRecordsWriteHandler(): void { // replace valid `encryption` property with a mismatching one message.encryption!.initializationVector = Encoder.stringToBase64Url('any value which will result in a different CID'); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4204,7 +4209,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4228,7 +4233,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4246,7 +4251,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(401); @@ -4261,7 +4266,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const tenant = await (await TestDataGenerator.generatePersona()).did; // unauthorized tenant const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); @@ -4294,7 +4299,7 @@ export function testRecordsWriteHandler(): void { const messageStore = stubInterface(); const dataStore = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const reply = await recordsWriteHandler.handle({ tenant, message, dataStream: dataStream! }); expect(reply.status.code).to.equal(400); @@ -4306,7 +4311,7 @@ export function testRecordsWriteHandler(): void { const bob = await DidKeyResolver.generate(); const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ author: alice, attesters: [alice, bob] }); - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4321,7 +4326,7 @@ export function testRecordsWriteHandler(): void { const anotherWrite = await TestDataGenerator.generateRecordsWrite({ attesters: [alice] }); message.attestation = anotherWrite.message.attestation; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4338,7 +4343,7 @@ export function testRecordsWriteHandler(): void { const attestationNotReferencedByAuthorization = await RecordsWrite['createAttestation'](descriptorCid, Jws.createSigners([bob])); message.attestation = attestationNotReferencedByAuthorization; - const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog); + const recordsWriteHandler = new RecordsWriteHandler(didResolver, messageStore, dataStore, eventLog, eventStream); const writeReply = await recordsWriteHandler.handle({ tenant: alice.did, message, dataStream: dataStream! }); expect(writeReply.status.code).to.equal(400); @@ -4359,14 +4364,15 @@ export function testRecordsWriteHandler(): void { const { message, dataStream } = await TestDataGenerator.generateFromRecordsWrite({ author, existingWrite: initialWrite }); const tenant = author.did; - const didResolverStub = TestStubGenerator.createDidResolverStub(author); const messageStoreStub = stubInterface(); messageStoreStub.query.resolves({ messages: [ initialWriteMessage ] }); const dataStoreStub = stubInterface(); - const recordsWriteHandler = new RecordsWriteHandler(didResolverStub, messageStoreStub, dataStoreStub, eventLog); + + const recordsWriteHandler = new RecordsWriteHandler(didResolverStub, messageStoreStub, dataStoreStub, eventLog, eventStream); + // simulate throwing unexpected error sinon.stub(recordsWriteHandler as any, 'processMessageWithoutDataStream').throws(new Error('an unknown error in recordsWriteHandler.processMessageWithoutDataStream()')); sinon.stub(recordsWriteHandler as any, 'processMessageWithDataStream').throws(new Error('an unknown error in recordsWriteHandler.processMessageWithDataStream()')); diff --git a/tests/interfaces/events-query.spec.ts b/tests/interfaces/events-query.spec.ts index 00039eb4d..c9bd9c94e 100644 --- a/tests/interfaces/events-query.spec.ts +++ b/tests/interfaces/events-query.spec.ts @@ -1,4 +1,4 @@ -import type { EventsQueryMessage } from '../../src/types/event-types.js'; +import type { EventsQueryMessage } from '../../src/types/events-types.js'; import type { ProtocolsQueryFilter } from '../../src/types/protocols-types.js'; import type { RecordsFilter } from '../../src/types/records-types.js'; diff --git a/tests/interfaces/events-subscribe.spec.ts b/tests/interfaces/events-subscribe.spec.ts new file mode 100644 index 000000000..09a531012 --- /dev/null +++ b/tests/interfaces/events-subscribe.spec.ts @@ -0,0 +1,27 @@ +import { authorizeOwner } from '../../src/core/auth.js'; +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; +import { DidKeyResolver, DwnInterfaceName, DwnMethodName, Jws, Time } from '../../src/index.js'; + +import { expect } from 'chai'; + +describe('EventsSubscribe', () => { + describe('create()', () => { + it('should be able to create and authorize EventsSubscribe', async () => { + const alice = await DidKeyResolver.generate(); + const timestamp = Time.getCurrentTimestamp(); + const eventsSubscribe = await EventsSubscribe.create({ + signer : Jws.createSigner(alice), + messageTimestamp : timestamp, + }); + + const message = eventsSubscribe.message; + expect(message.descriptor.interface).to.eql(DwnInterfaceName.Events); + expect(message.descriptor.method).to.eql(DwnMethodName.Subscribe); + expect(message.authorization).to.exist; + expect(message.descriptor.messageTimestamp).to.equal(timestamp); + + // EventsSubscribe authorizes against owner + await authorizeOwner(alice.did, eventsSubscribe); + }); + }); +}); diff --git a/tests/scenarios/delegated-grant.spec.ts b/tests/scenarios/delegated-grant.spec.ts index fd1388449..74e533db7 100644 --- a/tests/scenarios/delegated-grant.spec.ts +++ b/tests/scenarios/delegated-grant.spec.ts @@ -1,3 +1,4 @@ +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, PermissionScope } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -16,6 +17,7 @@ import { DwnErrorCode } from '../../src/core/dwn-error.js'; import { Jws } from '../../src/utils/jws.js'; import { RecordsWrite } from '../../src/interfaces/records-write.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { Time } from '../../src/utils/time.js'; @@ -29,6 +31,7 @@ export function testDelegatedGrantScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -41,7 +44,9 @@ export function testDelegatedGrantScenarios(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { @@ -179,7 +184,7 @@ export function testDelegatedGrantScenarios(): void { expect(carolWriteReply.status.detail).to.contain(DwnErrorCode.RecordsValidateIntegrityGrantedToAndSignerMismatch); }); - it('should only allow correct entity invoking a delegated grant to read or query', async () => { + it('should only allow correct entity invoking a delegated grant to read and query ', async () => { // scenario: // 1. Alice creates read and query delegated grants for device X, // 2. Bob starts a chat thread with Alice on his DWN diff --git a/tests/scenarios/end-to-end-tests.spec.ts b/tests/scenarios/end-to-end-tests.spec.ts index 3582a91ca..7d2dd27f8 100644 --- a/tests/scenarios/end-to-end-tests.spec.ts +++ b/tests/scenarios/end-to-end-tests.spec.ts @@ -1,4 +1,5 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; +import type { EventStream } from '../../src/types/subscriptions.js'; import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage, RecordsReadReply } from '../../src/index.js'; import chaiAsPromised from 'chai-as-promised'; @@ -11,6 +12,7 @@ import { Encoder } from '../../src/index.js'; import { HdKey } from '../../src/utils/hd-key.js'; import { KeyDerivationScheme } from '../../src/utils/hd-key.js'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; import { TestStores } from '../test-stores.js'; import { TestStubGenerator } from '../utils/test-stub-generator.js'; @@ -25,6 +27,7 @@ export function testEndToEndScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -37,7 +40,9 @@ export function testEndToEndScenarios(): void { dataStore = stores.dataStore; eventLog = stores.eventLog; - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/scenarios/events-query.spec.ts b/tests/scenarios/events-query.spec.ts index 246b1581f..2532fe72f 100644 --- a/tests/scenarios/events-query.spec.ts +++ b/tests/scenarios/events-query.spec.ts @@ -1,6 +1,7 @@ import type { DataStore, EventLog, + EventStream, MessageStore } from '../../src/index.js'; @@ -12,6 +13,7 @@ import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMet import { expect } from 'chai'; import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; export function testEventsQueryScenarios(): void { describe('events query tests', () => { @@ -19,6 +21,7 @@ export function testEventsQueryScenarios(): void { let messageStore: MessageStore; let dataStore: DataStore; let eventLog: EventLog; + let eventStream: EventStream; let dwn: Dwn; // important to follow the `before` and `after` pattern to initialize and clean the stores in tests @@ -30,8 +33,9 @@ export function testEventsQueryScenarios(): void { messageStore = stores.messageStore; dataStore = stores.dataStore; eventLog = stores.eventLog; + eventStream = TestEventStream.get(); - dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); }); beforeEach(async () => { diff --git a/tests/scenarios/subscriptions.spec.ts b/tests/scenarios/subscriptions.spec.ts new file mode 100644 index 000000000..dae65cfb3 --- /dev/null +++ b/tests/scenarios/subscriptions.spec.ts @@ -0,0 +1,928 @@ +import type { + DataStore, + EventLog, + EventStream, + GenericMessage, + MessageStore, +} from '../../src/index.js'; + +import freeForAll from '../vectors/protocol-definitions/free-for-all.json' assert { type: 'json' }; +import threadProtocol from '../vectors/protocol-definitions/thread-role.json' assert { type: 'json' }; + +import { TestDataGenerator } from '../utils/test-data-generator.js'; +import { TestEventStream } from '../test-event-stream.js'; +import { TestStores } from '../test-stores.js'; +import { Time } from '../../src/utils/time.js'; +import { DidKeyResolver, DidResolver, Dwn, DwnConstant, DwnInterfaceName, DwnMethodName, Message } from '../../src/index.js'; + +import { expect } from 'chai'; + +export function testSubscriptionScenarios(): void { + describe('subscriptions', () => { + let didResolver: DidResolver; + let messageStore: MessageStore; + let dataStore: DataStore; + let eventLog: EventLog; + let eventStream: EventStream; + let dwn: Dwn; + // important to follow the `before` and `after` pattern to initialize and clean the stores in tests + // so that different test suites can reuse the same backend store for testing + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + const stores = TestStores.get(); + messageStore = stores.messageStore; + dataStore = stores.dataStore; + eventLog = stores.eventLog; + eventStream = TestEventStream.get(); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog, eventStream }); + }); + + beforeEach(async () => { + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + describe('events subscribe', () => { + it('all events', async () => { + const alice = await DidKeyResolver.generate(); + + // create a handler that adds the messageCid of each message to an array. + const messageCids: string[] = []; + const handler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + + // subscribe to all messages + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { subscriptionHandler: handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); + expect(eventsSubscriptionReply.subscription?.id).to.equal(await Message.getCid(eventsSubscription.message)); + + // generate various messages + const write1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1MessageCid = await Message.getCid(write1.message); + const write1Reply = await dwn.processMessage(alice.did, write1.message, { dataStream: write1.dataStream }); + expect(write1Reply.status.code).to.equal(202); + + const grant1 = await TestDataGenerator.generatePermissionsGrant({ author: alice }); + const grant1MessageCid = await Message.getCid(grant1.message); + const grant1Reply = await dwn.processMessage(alice.did, grant1.message); + expect(grant1Reply.status.code).to.equal(202); + + const protocol1 = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); + const protocol1MessageCid = await Message.getCid(protocol1.message); + const protocol1Reply = await dwn.processMessage(alice.did, protocol1.message); + expect(protocol1Reply.status.code).to.equal(202); + + const deleteWrite1 = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1.message.recordId }); + const delete1MessageCid = await Message.getCid(deleteWrite1.message); + const deleteWrite1Reply = await dwn.processMessage(alice.did, deleteWrite1.message); + expect(deleteWrite1Reply.status.code).to.equal(202); + + // unregister the handler + await eventsSubscriptionReply.subscription?.close(); + + // create a message after + const write2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write2Reply = await dwn.processMessage(alice.did, write2.message, { dataStream: write2.dataStream }); + expect(write2Reply.status.code).to.equal(202); + + await Time.minimalSleep(); + + // test the messageCids array for the appropriate messages + expect(messageCids.length).to.equal(4); + expect(messageCids).to.eql([ write1MessageCid, grant1MessageCid, protocol1MessageCid, delete1MessageCid ]); + }); + + it('filters by interface type', async () => { + // scenario: + // alice subscribes to 3 different message interfaces (Permissions, Records, Grants) + // alice creates (3) messages, (RecordsWrite, PermissionsGrant and ProtocolsConfigure + // alice checks that each handler emitted the appropriate message + // alice deletes the record, and revokes the grant + // alice checks that the Records and Permissions handlers emitted the appropriate message + const alice = await DidKeyResolver.generate(); + + // subscribe to records + const recordsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Records }] + }); + const recordsMessageCids:string[] = []; + const recordsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + recordsMessageCids.push(messageCid); + }; + + const recordsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + recordsInterfaceSubscription.message, + { subscriptionHandler: recordsSubscribeHandler } + ); + expect(recordsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(recordsInterfaceSubscriptionReply.subscription).to.exist; + + // subscribe to permissions + const permissionsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Permissions }] + }); + const permissionsMessageCids:string[] = []; + const permissionsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + permissionsMessageCids.push(messageCid); + }; + + const permissionsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + permissionsInterfaceSubscription.message, + { subscriptionHandler: permissionsSubscribeHandler } + ); + expect(permissionsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(permissionsInterfaceSubscriptionReply.subscription).to.exist; + + // subscribe to protocols + const protocolsInterfaceSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Protocols }] + }); + const protocolsMessageCids:string[] = []; + const protocolsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + protocolsMessageCids.push(messageCid); + }; + + const protocolsInterfaceSubscriptionReply = await dwn.processMessage( + alice.did, + protocolsInterfaceSubscription.message, + { subscriptionHandler: protocolsSubscribeHandler } + ); + expect(protocolsInterfaceSubscriptionReply.status.code).to.equal(200); + expect(protocolsInterfaceSubscriptionReply.subscription).to.exist; + + // create one of each message types + const record = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const grant = await TestDataGenerator.generatePermissionsGrant({ author: alice }); + const protocol = await TestDataGenerator.generateProtocolsConfigure({ author: alice }); + + // insert data + const recordReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream }); + const grantReply = await dwn.processMessage(alice.did, grant.message); + const protocolReply = await dwn.processMessage(alice.did, protocol.message); + expect(recordReply.status.code).to.equal(202, 'RecordsWrite'); + expect(grantReply.status.code).to.equal(202, 'PermissionsGrant'); + expect(protocolReply.status.code).to.equal(202, 'ProtocolConfigure'); + + // check record message + expect(recordsMessageCids.length).to.equal(1); + expect(recordsMessageCids).to.have.members([ await Message.getCid(record.message) ]); + + // check permissions message + expect(permissionsMessageCids.length).to.equal(1); + expect(permissionsMessageCids).to.have.members([ await Message.getCid(grant.message) ]); + + // check protocols message + expect(protocolsMessageCids.length).to.equal(1); + expect(protocolsMessageCids).to.have.members([ await Message.getCid(protocol.message) ]); + + // insert additional data to query beyond a cursor + const recordDelete = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: record.message.recordId }); + const revokeGrant = await TestDataGenerator.generatePermissionsRevoke({ + author: alice, permissionsGrantId: await Message.getCid(grant.message) + }); + const recordDeleteReply = await dwn.processMessage(alice.did, recordDelete.message); + const revokeGrantReply = await dwn.processMessage(alice.did, revokeGrant.message); + expect(recordDeleteReply.status.code).to.equal(202, 'RecordsDelete'); + expect(revokeGrantReply.status.code).to.equal(202, 'PermissionsRevoke'); + + // check record messages to include the delete message + expect(recordsMessageCids.length).to.equal(2); + expect(recordsMessageCids).to.include.members([ await Message.getCid(recordDelete.message) ]); + + // check permissions messages to include the revoke message + expect(permissionsMessageCids.length).to.equal(2); + expect(permissionsMessageCids).to.include.members([ await Message.getCid(revokeGrant.message) ]); + + // protocols remains unchanged + expect(protocolsMessageCids.length).to.equal(1); + }); + + it('filters by method type', async () => { + // scenario: + // alice creates a variety of Messages (RecordsWrite, RecordsDelete, ProtocolConfigure, PermissionsGrant) + // alice queries for only RecordsWrite messages + // alice creates more messages to query beyond a cursor + + const alice = await DidKeyResolver.generate(); + + // subscribe to records write + const recordsWriteSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ interface: DwnInterfaceName.Records, method: DwnMethodName.Write }] + }); + const recordsMessageCids:string[] = []; + const recordsSubscribeHandler = async (message: GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + recordsMessageCids.push(messageCid); + }; + + const recordsWriteSubscriptionReply = await dwn.processMessage( + alice.did, + recordsWriteSubscription.message, + { subscriptionHandler: recordsSubscribeHandler } + ); + expect(recordsWriteSubscriptionReply.status.code).to.equal(200); + expect(recordsWriteSubscriptionReply.subscription).to.exist; + + // create one of each message types + const record = await TestDataGenerator.generateRecordsWrite({ author: alice }); + + // insert data + const recordReply = await dwn.processMessage(alice.did, record.message, { dataStream: record.dataStream }); + expect(recordReply.status.code).to.equal(202, 'RecordsWrite'); + + // sleep to make sure event was processed and added to array asynchronously + await Time.minimalSleep(); + + // check record message + expect(recordsMessageCids.length).to.equal(1); + expect(recordsMessageCids).to.have.members([ await Message.getCid(record.message) ]); + + // delete the message + const recordDelete = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: record.message.recordId }); + const recordDeleteReply = await dwn.processMessage(alice.did, recordDelete.message); + expect(recordDeleteReply.status.code).to.equal(202, 'RecordsDelete'); + + // check record messages remain unchanged and do not include the delete since we only subscribe to writes + expect(recordsMessageCids.length).to.equal(1); + }); + + it('filters by a protocol across different message types', async () => { + // scenario: + // alice creates (3) different message types all related to "proto1" (Configure, RecordsWrite, RecordsDelete) + // alice creates (3) different message types all related to "proto2" (Configure, RecordsWrite, RecordsDelete) + // when subscribing for a specific protocol, only Messages related to it should be emitted. + const alice = await DidKeyResolver.generate(); + + const proto1Messages:string[] = []; + const proto1Handler = async (message:GenericMessage):Promise => { + proto1Messages.push(await Message.getCid(message)); + }; + + const proto1Subscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ protocol: 'http://proto1' }] + }); + const proto1SubscriptionReply = await dwn.processMessage(alice.did, proto1Subscription.message, { + subscriptionHandler: proto1Handler + }); + expect(proto1SubscriptionReply.status.code).to.equal(200); + expect(proto1SubscriptionReply.subscription).to.exist; + + const proto2Messages:string[] = []; + const proto2Handler = async (message:GenericMessage):Promise => { + proto2Messages.push(await Message.getCid(message)); + }; + + const proto2Subscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ protocol: 'http://proto2' }] + }); + const proto2SubscriptionReply = await dwn.processMessage(alice.did, proto2Subscription.message, { + subscriptionHandler: proto2Handler + }); + expect(proto2SubscriptionReply.status.code).to.equal(200); + expect(proto2SubscriptionReply.subscription).to.exist; + + // create a proto1 + const protoConf1 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'http://proto1' } + }); + + const postProperties = { + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + const proto1 = protoConf1.message.descriptor.definition.protocol; + const protoConf1Response = await dwn.processMessage(alice.did, protoConf1.message); + expect(protoConf1Response.status.code).equals(202); + + // create a proto2 + const protoConf2 = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll, protocol: 'http://proto2' } + }); + const proto2 = protoConf2.message.descriptor.definition.protocol; + const protoConf2Response = await dwn.processMessage(alice.did, protoConf2.message); + expect(protoConf2Response.status.code).equals(202); + + // create a record for proto1 + const write1proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto1, ...postProperties }); + const write1Response = await dwn.processMessage(alice.did, write1proto1.message, { dataStream: write1proto1.dataStream }); + expect(write1Response.status.code).equals(202); + + // create a record for proto2 + const write1proto2 = await TestDataGenerator.generateRecordsWrite({ author: alice, protocol: proto2, ...postProperties }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1proto2.message, { dataStream: write1proto2.dataStream }); + expect(write1Proto2Response.status.code).equals(202); + + // check for proto1 messages + expect(proto1Messages.length).to.equal(2); + expect(proto1Messages).to.have.members([ await Message.getCid(protoConf1.message), await Message.getCid(write1proto1.message) ]); + + // check for proto2 messages + expect(proto2Messages.length).to.equal(2); + expect(proto2Messages).to.have.members([ await Message.getCid(protoConf2.message), await Message.getCid(write1proto2.message) ]); + + // delete proto1 message + const deleteProto1Message = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1proto1.message.recordId }); + const deleteProto1MessageReply = await dwn.processMessage(alice.did, deleteProto1Message.message); + expect(deleteProto1MessageReply.status.code).to.equal(202); + + // delete proto2 message + const deleteProto2Message = await TestDataGenerator.generateRecordsDelete({ author: alice, recordId: write1proto2.message.recordId }); + const deleteProto2MessageReply = await dwn.processMessage(alice.did, deleteProto2Message.message); + expect(deleteProto2MessageReply.status.code).to.equal(202); + + // check for the delete in proto1 messages + expect(proto1Messages.length).to.equal(3); + expect(proto1Messages).to.include.members([ await Message.getCid(deleteProto1Message.message) ]); + + // check for the delete in proto2 messages + expect(proto2Messages.length).to.equal(3); + expect(proto2Messages).to.include.members([ await Message.getCid(deleteProto2Message.message) ]); + }); + + it('filters by protocol & parentId across multiple protocolPaths', async () => { + // scenario: subscribe to multiple protocolPaths for a given protocol and parentId + // alice installs a protocol and creates a thread + // alice subscribes to update to that thread, it's participant as well as thread chats + // alice adds bob and carol as participants to the thread + // alice, bob, and carol all create messages + // alice deletes carol participant message + // alice checks that the correct messages were omitted + + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + const carol = await DidKeyResolver.generate(); + + // create protocol + const protocolConfigure = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...threadProtocol } + }); + const protocolConfigureReply = await dwn.processMessage(alice.did, protocolConfigure.message); + expect(protocolConfigureReply.status.code).to.equal(202); + const protocol = protocolConfigure.message.descriptor.definition.protocol; + + // alice creates thread + const thread = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocol, + protocolPath : 'thread' + }); + const threadReply = await dwn.processMessage(alice.did, thread.message, { dataStream: thread.dataStream }); + expect(threadReply.status.code).to.equal(202); + + + // subscribe to this thread's events + const messages:string[] = []; + const subscriptionHandler = async (message:GenericMessage):Promise => { + messages.push(await Message.getCid(message)); + }; + + const threadSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [ + { protocol: protocol, protocolPath: 'thread', parentId: thread.message.recordId }, // thread updates + { protocol: protocol, protocolPath: 'thread/participant', parentId: thread.message.recordId }, // participant updates + { protocol: protocol, protocolPath: 'thread/chat', parentId: thread.message.recordId } // chat updates + ], + }); + const threadSubscriptionReply = await dwn.processMessage(alice.did, threadSubscription.message, { + subscriptionHandler + }); + expect(threadSubscriptionReply.status.code).to.equal(200); + expect(threadSubscriptionReply.subscription).to.exist; + + // add bob as participant + const bobParticipant = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : bob.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/participant' + }); + const bobParticipantReply = await dwn.processMessage(alice.did, bobParticipant.message, { dataStream: bobParticipant.dataStream }); + expect(bobParticipantReply.status.code).to.equal(202); + + // add carol as participant + const carolParticipant = await TestDataGenerator.generateRecordsWrite({ + author : alice, + recipient : carol.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/participant' + }); + const carolParticipantReply = await dwn.processMessage(alice.did, carolParticipant.message, { dataStream: carolParticipant.dataStream }); + expect(carolParticipantReply.status.code).to.equal(202); + + // add another thread as a control, will not show up in handled events + const additionalThread = await TestDataGenerator.generateRecordsWrite({ + author : alice, + protocol : protocol, + protocolPath : 'thread' + }); + const additionalThreadReply = await dwn.processMessage(alice.did, additionalThread.message, { dataStream: additionalThread.dataStream }); + expect(additionalThreadReply.status.code).to.equal(202); + + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); + + expect(messages.length).to.equal(2); + expect(messages).to.have.members([ + await Message.getCid(bobParticipant.message), + await Message.getCid(carolParticipant.message), + ]); + + // add a message to protocol1 + const message1 = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message1Reply = await dwn.processMessage(alice.did, message1.message, { dataStream: message1.dataStream }); + expect(message1Reply.status.code).to.equal(202); + + const message2 = await TestDataGenerator.generateRecordsWrite({ + author : bob, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message2Reply = await dwn.processMessage(alice.did, message2.message, { dataStream: message2.dataStream }); + expect(message2Reply.status.code).to.equal(202); + + const message3 = await TestDataGenerator.generateRecordsWrite({ + author : carol, + recipient : alice.did, + parentId : thread.message.recordId, + contextId : thread.message.contextId, + protocol : protocol, + protocolPath : 'thread/chat', + protocolRole : 'thread/participant', + }); + const message3Reply = await dwn.processMessage(alice.did, message3.message, { dataStream: message3.dataStream }); + expect(message3Reply.status.code).to.equal(202); + + // sleep in order to allow messages to process and check for the added messages + await Time.minimalSleep(); + expect(messages.length).to.equal(5); + expect(messages).to.include.members([ + await Message.getCid(message1.message), + await Message.getCid(message2.message), + await Message.getCid(message3.message), + ]); + + // delete carol participant + const deleteCarol = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : carolParticipant.message.recordId + }); + const deleteCarolReply = await dwn.processMessage(alice.did, deleteCarol.message); + expect(deleteCarolReply.status.code).to.equal(202); + + // sleep in order to allow messages to process and check for the delete message + await Time.minimalSleep(); + expect(messages.length).to.equal(6); + expect(messages).to.include.members([ + await Message.getCid(deleteCarol.message) + ]); + }); + + it('filters by schema', async () => { + const alice = await DidKeyResolver.generate(); + + // we will add messageCids to these arrays as they are received by their handler to check against later + const schema1Messages:string[] = []; + const schema2Messages:string[] = []; + + // we add a handler to the subscription and add the messageCid to the appropriate array + const schema1Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + schema1Messages.push(messageCid); + }; + + // subscribe to schema1 messages + const schema1Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ schema: 'http://schema1' }] }); + const schema1SubscriptionReply = await dwn.processMessage(alice.did, schema1Subscription.message, { subscriptionHandler: schema1Handler }); + expect(schema1SubscriptionReply.status.code).to.equal(200); + expect(schema1SubscriptionReply.subscription?.id).to.equal(await Message.getCid(schema1Subscription.message)); + + // we add a handler to the subscription and add the messageCid to the appropriate array + const schema2Handler = async (message:GenericMessage):Promise => { + const messageCid = await Message.getCid(message); + schema2Messages.push(messageCid); + }; + + // subscribe to schema2 messages + const schema2Subscription = await TestDataGenerator.generateEventsSubscribe({ author: alice, filters: [{ schema: 'http://schema2' }] }); + const schema2SubscriptionReply = await dwn.processMessage(alice.did, schema2Subscription.message, { subscriptionHandler: schema2Handler }); + expect(schema2SubscriptionReply.status.code).to.equal(200); + expect(schema2SubscriptionReply.subscription?.id).to.equal(await Message.getCid(schema2Subscription.message)); + + // create some random record, will not show up in records subscription + const write1Random = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const write1RandomResponse = await dwn.processMessage(alice.did, write1Random.message, { dataStream: write1Random.dataStream }); + expect(write1RandomResponse.status.code).to.equal(202); + + // create a record for schema1 + const write1schema1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema1' }); + const write1Response = await dwn.processMessage(alice.did, write1schema1.message, { dataStream: write1schema1.dataStream }); + expect(write1Response.status.code).equals(202); + + // create a record for schema2 + const write1schema2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema2' }); + const write1Proto2Response = await dwn.processMessage(alice.did, write1schema2.message, { dataStream: write1schema2.dataStream }); + expect(write1Proto2Response.status.code).equals(202); + + expect(schema1Messages.length).to.equal(1, 'schema1'); + expect(schema1Messages).to.include(await Message.getCid(write1schema1.message)); + expect(schema2Messages.length).to.equal(1, 'schema2'); + expect(schema2Messages).to.include(await Message.getCid(write1schema2.message)); + + // remove listener for schema1 + schema1SubscriptionReply.subscription?.close(); + + // create another record for schema1 + const write2proto1 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema1' }); + const write2Response = await dwn.processMessage(alice.did, write2proto1.message, { dataStream: write2proto1.dataStream }); + expect(write2Response.status.code).equals(202); + + // create another record for schema2 + const write2schema2 = await TestDataGenerator.generateRecordsWrite({ author: alice, schema: 'http://schema2' }); + const write2Schema2Response = await dwn.processMessage(alice.did, write2schema2.message, { dataStream: write2schema2.dataStream }); + expect(write2Schema2Response.status.code).equals(202); + + // schema1 messages from handler do not change. + expect(schema1Messages.length).to.equal(1, 'schema1 after close()'); + expect(schema1Messages).to.include(await Message.getCid(write1schema1.message)); + + // schema2 messages from handler have the new message. + expect(schema2Messages.length).to.equal(2, 'schema2 after close()'); + expect(schema2Messages).to.have.members([await Message.getCid(write1schema2.message), await Message.getCid(write2schema2.message)]); + }); + + it('filters by recordId', async () => { + const alice = await DidKeyResolver.generate(); + + const write = await TestDataGenerator.generateRecordsWrite({ + author : alice, + schema : 'schema1' + }); + const write1Reply = await dwn.processMessage(alice.did, write.message, { dataStream: write.dataStream }); + expect(write1Reply.status.code).to.equal(202); + + // create a subscription and capture the messages associated with the record + const messages: string[] = []; + const subscriptionHandler = async (message: GenericMessage):Promise => { + messages.push(await Message.getCid(message)); + }; + + const recordIdSubscribe = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ recordId: write.message.recordId }] + }); + const recordIdSubscribeReply = await dwn.processMessage(alice.did, recordIdSubscribe.message, { + subscriptionHandler + }); + expect(recordIdSubscribeReply.status.code).to.equal(200); + + // a write as a control, will not show up in subscription + const controlWrite = await TestDataGenerator.generateRecordsWrite({ + author : alice, + schema : 'schema1' + }); + const write2Reply = await dwn.processMessage(alice.did, controlWrite.message, { dataStream: controlWrite.dataStream }); + expect(write2Reply.status.code).to.equal(202); + + // update record + const update = await TestDataGenerator.generateFromRecordsWrite({ + author : alice, + existingWrite : write.recordsWrite, + }); + const updateReply = await dwn.processMessage(alice.did, update.message, { dataStream: update.dataStream }); + expect(updateReply.status.code).to.equal(202); + + + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); + + expect(messages.length).to.equal(1); + expect(messages).to.have.members([ await Message.getCid(update.message) ]); + + const deleteRecord = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : write.message.recordId, + }); + const deleteRecordReply = await dwn.processMessage(alice.did, deleteRecord.message); + expect(deleteRecordReply.status.code).to.equal(202); + + // sleep to allow all messages to be processed by the handler message + await Time.minimalSleep(); + + expect(messages.length).to.equal(2); + expect(messages).to.include.members([ await Message.getCid(deleteRecord.message) ]); + }); + + it('filters by recipient', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + const carol = await DidKeyResolver.generate(); + + const receivedMessages:string[] = []; + + const handler = async (message:GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + receivedMessages.push(messageCid); + }; + + const recipientSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ recipient: alice.did }] + }); + const authorQueryReply = await dwn.processMessage(alice.did, recipientSubscription.message, { subscriptionHandler: handler }); + expect(authorQueryReply.status.code).to.equal(200); + + const protocolConfigure = await TestDataGenerator.generateProtocolsConfigure({ + author : alice, + protocolDefinition : { ...freeForAll } + }); + const protocolConfigureReply = await dwn.processMessage(alice.did, protocolConfigure.message); + expect(protocolConfigureReply.status.code).to.equal(202); + const protocol = protocolConfigure.message.descriptor.definition.protocol; + + const postProperties = { + protocol : protocol, + protocolPath : 'post', + schema : freeForAll.types.post.schema, + dataFormat : freeForAll.types.post.dataFormats[0], + }; + + const messageFromBobToAlice = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : bob, + recipient : alice.did, + }); + const messageFromBobToAliceReply = + await dwn.processMessage(alice.did, messageFromBobToAlice.message, { dataStream: messageFromBobToAlice.dataStream }); + expect(messageFromBobToAliceReply.status.code).to.equal(202); + + const messageFromCarolToAlice = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : carol, + recipient : alice.did, + }); + const messageFromCarolToAliceReply = + await dwn.processMessage(alice.did, messageFromCarolToAlice.message, { dataStream: messageFromCarolToAlice.dataStream }); + expect(messageFromCarolToAliceReply.status.code).to.equal(202); + + const messageFromAliceToBob = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : bob.did, + }); + const messageFromAliceToBobReply = + await dwn.processMessage(alice.did, messageFromAliceToBob.message, { dataStream: messageFromAliceToBob.dataStream }); + expect(messageFromAliceToBobReply.status.code).to.equal(202); + + const messageFromAliceToCarol = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : carol.did, + }); + const messageFromAliceToCarolReply = + await dwn.processMessage(alice.did, messageFromAliceToCarol.message, { dataStream: messageFromAliceToCarol.dataStream }); + expect(messageFromAliceToCarolReply.status.code).to.equal(202); + + expect(receivedMessages).to.have.members([ + await Message.getCid(messageFromBobToAlice.message), + await Message.getCid(messageFromCarolToAlice.message) + ]); + + // add another message + const messageFromAliceToBob2 = await TestDataGenerator.generateRecordsWrite({ + ...postProperties, + author : alice, + recipient : bob.did, + }); + const messageFromAliceToBob2Reply = + await dwn.processMessage(alice.did, messageFromAliceToBob2.message, { dataStream: messageFromAliceToBob2.dataStream }); + expect(messageFromAliceToBob2Reply.status.code).to.equal(202); + + expect(receivedMessages).to.not.include.members([ await Message.getCid(messageFromAliceToBob2.message)]); + }); + + it('filters by dataFormat', async () => { + // scenario: alice stores different file types and needs events relating to `image/jpeg` + // alice creates 3 files, one of them `image/jpeg` + // alice queries for `image/jpeg` retrieving the one message + // alice adds another image to query for using the prior image as a cursor + + const alice = await DidKeyResolver.generate(); + + const imageMessages: string[] = []; + const imageHandler = async (message:GenericMessage):Promise => { + imageMessages.push(await Message.getCid(message)); + }; + + const imageSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ dataFormat: 'image/jpeg' }] + }); + const imageSubscriptionReply = await dwn.processMessage(alice.did, imageSubscription.message, { + subscriptionHandler: imageHandler + }); + expect(imageSubscriptionReply.status.code).to.equal(200); + + // write a text file + const textFile = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'application/text' + }); + const textFileReply = await dwn.processMessage(alice.did, textFile.message, { dataStream: textFile.dataStream }); + expect(textFileReply.status.code).to.equal(202); + + // write an application/json file + const jsonData = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'application/json' + }); + const jsonDataReply = await dwn.processMessage(alice.did, jsonData.message, { dataStream: jsonData.dataStream }); + expect(jsonDataReply.status.code).to.equal(202); + + // write an image + const imageData = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'image/jpeg' + }); + const imageDataReply = await dwn.processMessage(alice.did, imageData.message, { dataStream: imageData.dataStream }); + expect(imageDataReply.status.code).to.equal(202); + + + // wait for messages to emit and handler to process + await Time.minimalSleep(); + expect(imageMessages.length).to.equal(1); + expect(imageMessages).to.have.members([ await Message.getCid(imageData.message) ]); + + // add another image + const imageData2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + dataFormat : 'image/jpeg' + }); + const imageData2Reply = await dwn.processMessage(alice.did, imageData2.message, { dataStream: imageData2.dataStream }); + expect(imageData2Reply.status.code).to.equal(202); + + // delete the first image + const deleteImageData = await TestDataGenerator.generateRecordsDelete({ + author : alice, + recordId : imageData.message.recordId, + }); + const deleteImageDataReply = await dwn.processMessage(alice.did, deleteImageData.message); + expect(deleteImageDataReply.status.code).to.equal(202); + + // wait for messages to emit and handler to process + await Time.minimalSleep(); + expect(imageMessages.length).to.equal(3); + // check that the new image and the delete messages were emitted + expect(imageMessages).to.include.members([ + await Message.getCid(imageData2.message), + await Message.getCid(deleteImageData.message) + ]); + });; + + it('filters by dataSize', async () => { + // scenario: + // alice subscribes to messages with data size under a threshold + // alice inserts both small and large data + + const alice = await DidKeyResolver.generate(); + + const smallMessages: string[] = []; + const subscriptionHandler = async (message:GenericMessage):Promise => { + smallMessages.push(await Message.getCid(message)); + }; + const smallMessageSubscription = await TestDataGenerator.generateEventsSubscribe({ + author : alice, + filters : [{ dataSize: { lte: DwnConstant.maxDataSizeAllowedToBeEncoded } }] + }); + const smallMessageSubscriptionReply = await dwn.processMessage(alice.did, smallMessageSubscription.message, { + subscriptionHandler, + }); + expect(smallMessageSubscriptionReply.status.code).to.equal(200); + + // add a small data size record + const smallSize1 = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const smallSize1Reply = await dwn.processMessage(alice.did, smallSize1.message, { dataStream: smallSize1.dataStream }); + expect(smallSize1Reply.status.code).to.equal(202); + + // add a large data size record + const largeSize = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 1) + }); + const largeSizeReply = await dwn.processMessage(alice.did, largeSize.message, { dataStream: largeSize.dataStream }); + expect(largeSizeReply.status.code).to.equal(202); + + // wait for message handler to process and check results + await Time.minimalSleep(); + expect(smallMessages.length).to.equal(1); + expect(smallMessages).to.have.members([ await Message.getCid(smallSize1.message) ]); + + // add another small record + const smallSize2 = await TestDataGenerator.generateRecordsWrite({ + author: alice, + }); + const smallSize2Reply = await dwn.processMessage(alice.did, smallSize2.message, { dataStream: smallSize2.dataStream }); + expect(smallSize2Reply.status.code).to.equal(202); + + // add another large record + const largeSize2 = await TestDataGenerator.generateRecordsWrite({ + author : alice, + data : TestDataGenerator.randomBytes(DwnConstant.maxDataSizeAllowedToBeEncoded + 1) + }); + const largeSize2Reply = await dwn.processMessage(alice.did, largeSize2.message, { dataStream: largeSize2.dataStream }); + expect(largeSize2Reply.status.code).to.equal(202); + + // wait for message handler to process and check results + await Time.minimalSleep(); + expect(smallMessages.length).to.equal(2); + expect(smallMessages).to.include.members([ await Message.getCid(smallSize2.message) ]); + }); + + it('does not emit events after subscription is closed', async () => { + const alice = await DidKeyResolver.generate(); + + // messageCids of events + const messageCids:string[] = []; + + const handler = async (message: GenericMessage): Promise => { + const messageCid = await Message.getCid(message); + messageCids.push(messageCid); + }; + + // subscribe to all events + const eventsSubscription = await TestDataGenerator.generateEventsSubscribe({ author: alice }); + const eventsSubscriptionReply = await dwn.processMessage(alice.did, eventsSubscription.message, { subscriptionHandler: handler }); + expect(eventsSubscriptionReply.status.code).to.equal(200); + + expect(messageCids.length).to.equal(0); // no events exist yet + + const record1 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record1Reply = await dwn.processMessage(alice.did, record1.message, { dataStream: record1.dataStream }); + expect(record1Reply.status.code).to.equal(202); + const record1MessageCid = await Message.getCid(record1.message); + + expect(messageCids.length).to.equal(1); // message exists + expect(messageCids).to.eql([ record1MessageCid ]); + + // unsubscribe, this should be used as clean up. + await eventsSubscriptionReply.subscription!.close(); + + // write another message. + const record2 = await TestDataGenerator.generateRecordsWrite({ author: alice }); + const record2Reply = await dwn.processMessage(alice.did, record2.message, { dataStream: record2.dataStream }); + expect(record2Reply.status.code).to.equal(202); + + // sleep to make sure events have some time to emit. + await Time.minimalSleep(); + + expect(messageCids.length).to.equal(1); // same as before + expect(messageCids).to.eql([ record1MessageCid ]); + }); + }); + }); +} \ No newline at end of file diff --git a/tests/store/message-store.spec.ts b/tests/store/message-store.spec.ts index a0d1fbc2c..6860e6ebe 100644 --- a/tests/store/message-store.spec.ts +++ b/tests/store/message-store.spec.ts @@ -260,7 +260,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}]); @@ -280,7 +280,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { messageTimestamp: SortDirection.Ascending }); expect(messageQuery.length).to.equal(messages.length); @@ -298,7 +298,7 @@ export function testMessageStore(): void { dateCreated: TestDataGenerator.randomTimestamp(), }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { dateCreated: SortDirection.Ascending }); @@ -318,7 +318,7 @@ export function testMessageStore(): void { dateCreated: TestDataGenerator.randomTimestamp(), }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { dateCreated: SortDirection.Descending }); @@ -339,7 +339,7 @@ export function testMessageStore(): void { datePublished : TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { datePublished: SortDirection.Ascending }); @@ -360,7 +360,7 @@ export function testMessageStore(): void { datePublished : TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: messageQuery } = await messageStore.query(alice.did, [{}], { datePublished: SortDirection.Descending }); @@ -382,7 +382,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const { messages: limitQuery } = await messageStore.query(alice.did, [{}]); @@ -395,7 +395,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -416,7 +416,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } // get all of the records @@ -434,7 +434,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -457,7 +457,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const sortedRecords = messages.sort((a,b) => @@ -481,7 +481,7 @@ export function testMessageStore(): void { messageTimestamp: TestDataGenerator.randomTimestamp() }))); for (const message of messages) { - await messageStore.put(alice.did, message.message, await message.recordsWrite.constructRecordsWriteIndexes(true)); + await messageStore.put(alice.did, message.message, await message.recordsWrite.constructIndexes(true)); } const limit = 6; diff --git a/tests/test-event-stream.ts b/tests/test-event-stream.ts new file mode 100644 index 000000000..d27a9bfa1 --- /dev/null +++ b/tests/test-event-stream.ts @@ -0,0 +1,29 @@ +import type { EventStream } from '../src/index.js'; + +import { EventEmitterStream } from '../src/index.js'; + +/** + * Class that manages the EventStream implementation for testing. + * This is intended to be extended as the single point of configuration + * that allows different EventStream implementations to be swapped in + * to test compatibility with default/built-in implementation. + */ +export class TestEventStream { + private static eventStream?: EventStream; + + /** + * Overrides the event stream with a given implementation. + * If not given, default implementation will be used. + */ + public static override(overrides?: { eventStream?: EventStream }): void { + TestEventStream.eventStream = overrides?.eventStream; + } + + /** + * Initializes and returns the event stream used for running the test suite. + */ + public static get(): EventStream { + TestEventStream.eventStream ??= new EventEmitterStream(); + return TestEventStream.eventStream; + } +} \ No newline at end of file diff --git a/tests/test-stores.ts b/tests/test-stores.ts index db4dfb074..5761c3f9c 100644 --- a/tests/test-stores.ts +++ b/tests/test-stores.ts @@ -43,7 +43,7 @@ export class TestStores { return { messageStore : TestStores.messageStore, dataStore : TestStores.dataStore, - eventLog : TestStores.eventLog + eventLog : TestStores.eventLog, }; } } \ No newline at end of file diff --git a/tests/test-suite.ts b/tests/test-suite.ts index 21c373709..01e456b99 100644 --- a/tests/test-suite.ts +++ b/tests/test-suite.ts @@ -7,6 +7,7 @@ import { testEventLog } from './event-log/event-log.spec.js'; import { testEventsGetHandler } from './handlers/events-get.spec.js'; import { testEventsQueryHandler } from './handlers/events-query.spec.js'; import { testEventsQueryScenarios } from './scenarios/events-query.spec.js'; +import { testEventsSubscribeHandler } from './handlers/events-subscribe.spec.js'; import { testMessagesGetHandler } from './handlers/messages-get.spec.js'; import { testMessageStore } from './store/message-store.spec.js'; import { testPermissionsGrantHandler } from './handlers/permissions-grant.spec.js'; @@ -18,6 +19,7 @@ import { testRecordsQueryHandler } from './handlers/records-query.spec.js'; import { testRecordsReadHandler } from './handlers/records-read.spec.js'; import { testRecordsWriteHandler } from './handlers/records-write.spec.js'; import { TestStores } from './test-stores.js'; +import { testSubscriptionScenarios } from './scenarios/subscriptions.spec.js'; /** * Class for running DWN tests from an external repository that depends on this SDK. @@ -40,6 +42,7 @@ export class TestSuite { // handler tests testEventsGetHandler(); + testEventsSubscribeHandler(); testEventsQueryHandler(); testMessagesGetHandler(); testPermissionsGrantHandler(); @@ -55,5 +58,6 @@ export class TestSuite { testDelegatedGrantScenarios(); testEndToEndScenarios(); testEventsQueryScenarios(); + testSubscriptionScenarios(); } } \ No newline at end of file diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 0f6f1a9a9..146877f48 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -2,6 +2,7 @@ import type { DerivedPrivateJwk } from '../../src/utils/hd-key.js'; import type { DidResolutionResult } from '../../src/types/did-types.js'; import type { EventsGetOptions } from '../../src/interfaces/events-get.js'; import type { EventsQueryOptions } from '../../src/interfaces/events-query.js'; +import type { EventsSubscribeOptions } from '../../src/interfaces/events-subscribe.js'; import type { GeneralJws } from '../../src/types/jws-types.js'; import type { MessagesGetMessage } from '../../src/types/messages-types.js'; import type { MessagesGetOptions } from '../../src/interfaces/messages-get.js'; @@ -15,7 +16,7 @@ import type { Signer } from '../../src/types/signer.js'; import type { AuthorizationModel, Pagination } from '../../src/types/message-types.js'; import type { CreateFromOptions, EncryptionInput, KeyEncryptionInput, RecordsWriteOptions } from '../../src/interfaces/records-write.js'; import type { DateSort, RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage } from '../../src/types/records-types.js'; -import type { EventsGetMessage, EventsQueryFilter, EventsQueryMessage } from '../../src/types/event-types.js'; +import type { EventsFilter, EventsGetMessage, EventsQueryMessage, EventsSubscribeMessage } from '../../src/types/events-types.js'; import type { PermissionConditions, PermissionScope } from '../../src/types/permissions-grant-descriptor.js'; import type { PermissionsGrantMessage, PermissionsRequestMessage, PermissionsRevokeMessage } from '../../src/types/permissions-types.js'; import type { PrivateJwk, PublicJwk } from '../../src/types/jose-types.js'; @@ -29,6 +30,7 @@ import { DidKeyResolver } from '../../src/did/did-key-resolver.js'; import { Encryption } from '../../src/utils/encryption.js'; import { EventsGet } from '../../src/interfaces/events-get.js'; import { EventsQuery } from '../../src/interfaces/events-query.js'; +import { EventsSubscribe } from '../../src/interfaces/events-subscribe.js'; import { Jws } from '../../src/utils/jws.js'; import { MessagesGet } from '../../src/interfaces/messages-get.js'; import { PermissionsGrant } from '../../src/interfaces/permissions-grant.js'; @@ -237,7 +239,7 @@ export type GenerateEventsGetOutput = { export type GenerateEventsQueryInput = { author?: Persona; - filters: EventsQueryFilter[]; + filters: EventsFilter[]; cursor?: PaginationCursor; }; @@ -247,6 +249,18 @@ export type GenerateEventsQueryOutput = { message: EventsQueryMessage; }; +export type GenerateEventsSubscribeInput = { + author: Persona; + filters?: EventsFilter[]; + messageTimestamp?: string; +}; + +export type GenerateEventsSubscribeOutput = { + author: Persona; + eventsSubscribe: EventsSubscribe; + message: EventsSubscribeMessage; +}; + export type GenerateMessagesGetInput = { author?: Persona; messageCids: string[] @@ -766,6 +780,30 @@ export class TestDataGenerator { }; } + /** + * Generates a EventsSubscribe message for testing. + */ + public static async generateEventsSubscribe(input?: GenerateEventsSubscribeInput): Promise { + const author = input?.author ?? await TestDataGenerator.generatePersona(); + const signer = Jws.createSigner(author); + + const options: EventsSubscribeOptions = { + filters : input?.filters, + messageTimestamp : input?.messageTimestamp, + signer, + }; + removeUndefinedProperties(options); + + const eventsSubscribe = await EventsSubscribe.create(options); + const message = eventsSubscribe.message; + + return { + author, + eventsSubscribe, + message + }; + } + public static async generateMessagesGet(input: GenerateMessagesGetInput): Promise { const author = input?.author ?? await TestDataGenerator.generatePersona(); const signer = Jws.createSigner(author);