Skip to content

Commit

Permalink
Refactor message handling
Browse files Browse the repository at this point in the history
Introduce `latestAction` and `latestActionDetails`.
  • Loading branch information
splindsay-92 committed Nov 1, 2024
1 parent 6fb3143 commit 30cd42a
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 103 deletions.
Binary file modified ably-2.4.1.tgz
Binary file not shown.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/core/chat-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export class ChatApi {
new Date(message.createdAt),
metadata ?? {},
headers ?? {},
message.latestAction,
message.latestActionSerial,
message.deletedAt ? new Date(message.deletedAt) : undefined,
message.updatedAt ? new Date(message.updatedAt) : undefined,
message.latestActionDetails,
);
});
return data;
Expand Down
3 changes: 3 additions & 0 deletions src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export enum RealtimeMessageTypes {
* Chat Message Actions.
*/
export enum ChatMessageActions {
/** Represents a message with no action set. */
MessageUnset = 'message.unset',

/** Action applied to a new message. */
MessageCreate = 'message.create',

Expand Down
4 changes: 2 additions & 2 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type {
export { ConnectionLifecycle } from './connection-status.js';
export type { DiscontinuityListener, OnDiscontinuitySubscriptionResponse } from './discontinuity.js';
export { ErrorCodes, errorInfoIs } from './errors.js';
export { MessageEvents, PresenceEvents } from './events.js';
export { MessageEvents, PresenceEvents, ChatMessageActions } from './events.js';
export type { Headers } from './headers.js';
export {
ChatEntityType,
Expand All @@ -27,7 +27,7 @@ export {
} from './helpers.js';
export type { LogContext, Logger, LogHandler } from './logger.js';
export { LogLevel } from './logger.js';
export type { Message, MessageDetails, MessageDetailsMetadata, MessageHeaders, MessageMetadata } from './message.js';
export type { ActionDetails, Message, ActionDetailsMetadata, MessageHeaders, MessageMetadata } from './message.js';
export type {
MessageEventPayload,
MessageListener,
Expand Down
53 changes: 20 additions & 33 deletions src/core/message-parser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Ably from 'ably';

import { ChatMessageActions } from './events.js';
import { DefaultMessage, Message, MessageDetails, MessageHeaders, MessageMetadata } from './message.js';
import { ActionDetails, DefaultMessage, Message, MessageHeaders, MessageMetadata } from './message.js';

interface MessagePayload {
data?: {
Expand All @@ -16,7 +16,7 @@ interface MessagePayload {

serial: string;
updatedAt?: number;
deletedAt?: number;
updateSerial?: string;
action: Ably.MessageAction;
operation?: Ably.Operation;
}
Expand All @@ -29,12 +29,11 @@ interface ChatMessageFields {
createdAt: Date;
metadata: MessageMetadata;
headers: MessageHeaders;
deletedAt?: Date;
deletedBy?: string;
deletionDetail?: MessageDetails;
latestAction: ChatMessageActions;
latestActionSerial: string;
updatedAt?: Date;
updatedBy?: string;
updateDetail?: MessageDetails;
deletedAt?: Date;
operation?: ActionDetails;
}

export function parseMessage(roomId: string | undefined, inboundMessage: Ably.InboundMessage): Message {
Expand Down Expand Up @@ -67,14 +66,6 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In
throw new Ably.ErrorInfo(`received incoming message without serial`, 50000, 500);
}

let operationDetails: MessageDetails | undefined;
if (message.operation) {
operationDetails = {
description: message.operation.description,
metadata: message.operation.metadata,
};
}

const newMessage: ChatMessageFields = {
timeserial: message.serial,
clientId: message.clientId,
Expand All @@ -83,28 +74,25 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In
createdAt: new Date(message.timestamp),
metadata: message.data.metadata ?? {},
headers: message.extras.headers ?? {},
latestAction: message.action as ChatMessageActions,
latestActionSerial: message.updateSerial ?? message.serial,
updatedAt: message.updatedAt ? new Date(message.updatedAt) : undefined,
deletedAt: message.deletedAt ? new Date(message.deletedAt) : undefined,
deletedAt: message.updatedAt ? new Date(message.updatedAt) : undefined,
operation: message.operation as ActionDetails,
};

switch (message.action) {
case ChatMessageActions.MessageCreate: {
break;
}
case ChatMessageActions.MessageUpdate: {
case ChatMessageActions.MessageUpdate:
case ChatMessageActions.MessageDelete: {
if (!message.updatedAt) {
throw new Ably.ErrorInfo(`received incoming update message without updatedAt`, 50000, 500);
throw new Ably.ErrorInfo(`received incoming ${message.action} without updatedAt`, 50000, 500);
}
newMessage.updatedBy = message.operation?.clientId;
newMessage.updateDetail = operationDetails;
break;
}
case ChatMessageActions.MessageDelete: {
if (!message.deletedAt) {
throw new Ably.ErrorInfo(`received incoming deletion message without deletedAt`, 50000, 500);
if (!message.updateSerial) {
throw new Ably.ErrorInfo(`received incoming ${message.action} without updateSerial`, 50000, 500);
}
newMessage.deletedBy = message.operation?.clientId;
newMessage.deletionDetail = operationDetails;
break;
}
default: {
Expand All @@ -119,11 +107,10 @@ export function parseMessage(roomId: string | undefined, inboundMessage: Ably.In
newMessage.createdAt,
newMessage.metadata,
newMessage.headers,
newMessage.deletedAt,
newMessage.deletedBy,
newMessage.deletionDetail,
newMessage.updatedAt,
newMessage.updatedBy,
newMessage.updateDetail,
newMessage.latestAction,
newMessage.latestActionSerial,
newMessage.latestAction === ChatMessageActions.MessageDelete ? newMessage.deletedAt : undefined,
newMessage.latestAction === ChatMessageActions.MessageUpdate ? newMessage.updatedAt : undefined,
newMessage.operation,
);
}
143 changes: 99 additions & 44 deletions src/core/message.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ErrorInfo } from 'ably';

import { ChatMessageActions } from './events.js';
import { Headers } from './headers.js';
import { DetailsMetadata, Metadata } from './metadata.js';
import { DefaultTimeserial, Timeserial } from './timeserial.js';
Expand All @@ -13,22 +16,26 @@ export type MessageHeaders = Headers;
export type MessageMetadata = Metadata;

/**
* {@link DetailsMetadata} type for a chat messages {@link MessageDetails}.
* {@link DetailsMetadata} type for a chat messages {@link ActionDetails}.
*/
export type MessageDetailsMetadata = DetailsMetadata;
export type ActionDetailsMetadata = DetailsMetadata;

/**
* Represents the detail of a message deletion or update.
*/
export interface MessageDetails {
export interface ActionDetails {
/**
* The optional clientId of the user who performed the update or deletion.
*/
clientId?: string;
/**
* The optional description for the update or deletion.
*/
description?: string;
/**
* The optional {@link MessageDetailsMetadata} associated with the update or deletion.
* The optional {@link ActionDetailsMetadata} associated with the update or deletion.
*/
metadata?: MessageDetailsMetadata;
metadata?: ActionDetailsMetadata;
}

/**
Expand Down Expand Up @@ -90,55 +97,72 @@ export interface Message {
readonly headers: MessageHeaders;

/**
* The timestamp at which the message was deleted. If the message has not been deleted, this
* value is undefined.
* The latest action of the message. This can be used to determine if the message was created, updated, or deleted.
*/
readonly deletedAt?: Date;
readonly latestAction: ChatMessageActions;

/**
* The clientId of the user who deleted the message.
* If the message has not been deleted, or has been deleted by a connection without a clientId (such as requests made
* using an API key only), this value is undefined.
* A unique identifier for the latest action that updated the message. This is only set for update and deletes.
*/
readonly deletedBy?: string;
readonly latestActionSerial: string;

/**
* The {@link MessageDetails} of the deletion. If the message has not been deleted, this value is undefined.
* Contains the optional description for deletion and any additional optional metadata associated with the
* deletion.
* The details of the latest action that updated the message. This is only set for update and delete actions.
*/
readonly deletionDetail?: MessageDetails;
readonly latestActionDetails?: ActionDetails;

/**
* The timestamp at which the message was updated.
* If the message has not been updated, this value is undefined.
* Indicates if the message has been updated.
*/
readonly updatedAt?: Date;
readonly isUpdated: boolean;

/**
* The clientId of the user who updated the message.
* If the message has not been updated, or has been updated by a connection without a clientId (such as requests made
* using an API key only), this value is undefined.
* Indicates if the message has been deleted.
* // TODO - Make a note of this.
* An important note, when messages are sent cross-region, it is possible for a deleted message to be updated.
* This is because the delete message may not have reached all regions before the update message.
* However, cross-region replication delay is typically very low, so this is a rare edge case.
*/
readonly updatedBy?: string;
readonly isDeleted: boolean;

/**
* The {@link MessageDetails} of the latest update. If the message has not been updated, this value is undefined.
* Contains the optional reason for update and any additional optional metadata associated with the update.
* The clientId of the user who deleted or updated the message.
*/
readonly updateDetail?: MessageDetails;
readonly actionedBy?: string;

/**
* Determines if this message has been deleted.
* @returns true if the message has been deleted.
* The timestamp at which the message was deleted.
*/
isDeleted(): boolean;
readonly deletedAt?: Date;

/**
* Determines if this message has been updated.
* @returns true if the message has been updated.
* The timestamp at which the message was updated.
*/
readonly updatedAt?: Date;

/**
* Determines if the action of this message is before the given message.
* @param message The message to compare against.
* @returns true if the action of this message is before the given message.
* @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid.
*/
actionBefore(message: Message): boolean;

/**
* Determines if the action of this message is after the given message.
* @param message The message to compare against.
* @returns true if the action of this message is after the given message.
* @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid.
*/
isUpdated(): boolean;
actionAfter(message: Message): boolean;

/**
* Determines if the action of this message is equal to the given message.
* @param message The message to compare against.
* @returns true if the action of this message is equal to the given message.
* @throws {@link ErrorInfo} if both message timeserials do not match, or if updateSerial of either is invalid.
*/
actionEqual(message: Message): boolean;

/**
* Determines if this message was created before the given message. This comparison is based on
Expand Down Expand Up @@ -175,7 +199,8 @@ export interface Message {
* Allows for comparison of messages based on their timeserials.
*/
export class DefaultMessage implements Message {
private readonly _calculatedTimeserial: Timeserial;
private readonly _calculatedOriginTimeserial: Timeserial;
private readonly _calculatedActionSerial: Timeserial;

constructor(
public readonly timeserial: string,
Expand All @@ -185,36 +210,66 @@ export class DefaultMessage implements Message {
public readonly createdAt: Date,
public readonly metadata: MessageMetadata,
public readonly headers: MessageHeaders,

public readonly latestAction: ChatMessageActions,
public readonly latestActionSerial: string,

public readonly deletedAt?: Date,
public readonly deletedBy?: string,
public readonly deletionDetail?: MessageDetails,
public readonly updatedAt?: Date,
public readonly updatedBy?: string,
public readonly updateDetail?: MessageDetails,
public readonly latestActionDetails?: ActionDetails,
) {
this._calculatedTimeserial = DefaultTimeserial.calculateTimeserial(timeserial);
this._calculatedOriginTimeserial = DefaultTimeserial.calculateTimeserial(timeserial);
this._calculatedActionSerial = DefaultTimeserial.calculateTimeserial(latestActionSerial);

// The object is frozen after constructing to enforce readonly at runtime too
Object.freeze(this);
}

isDeleted(): boolean {
get isUpdated(): boolean {
return this.updatedAt !== undefined;
}

get isDeleted(): boolean {
return this.deletedAt !== undefined;
}

isUpdated(): boolean {
return this.updatedAt !== undefined;
get actionedBy(): string | undefined {
return this.latestActionDetails?.clientId;
}

actionBefore(message: Message): boolean {
// Check to ensure the messages are the same before comparing operation order
if (!this.equal(message)) {
throw new ErrorInfo('operationBefore(): Cannot compare actions, message timeserials must be equal', 50000, 500);
}
return this._calculatedActionSerial.before(message.latestActionSerial);
}

actionAfter(message: Message): boolean {
// Check to ensure the messages are the same before comparing operation order
if (!this.equal(message)) {
throw new ErrorInfo('operationBefore(): Cannot compare actions, message timeserials must be equal', 50000, 500);
}
return this._calculatedActionSerial.after(message.latestActionSerial);
}

actionEqual(message: Message): boolean {
// Check to ensure the messages are the same before comparing operation order
if (!this.equal(message)) {
throw new ErrorInfo('operationBefore(): Cannot compare actions, message timeserials must be equal', 50000, 500);
}
return this._calculatedActionSerial.equal(message.latestActionSerial);
}

before(message: Message): boolean {
return this._calculatedTimeserial.before(message.timeserial);
return this._calculatedOriginTimeserial.before(message.timeserial);
}

after(message: Message): boolean {
return this._calculatedTimeserial.after(message.timeserial);
return this._calculatedOriginTimeserial.after(message.timeserial);
}

equal(message: Message): boolean {
return this._calculatedTimeserial.equal(message.timeserial);
return this._calculatedOriginTimeserial.equal(message.timeserial);
}
}
2 changes: 2 additions & 0 deletions src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ export class DefaultMessages
new Date(response.createdAt),
metadata ?? {},
headers ?? {},
ChatMessageActions.MessageCreate,
response.timeserial,
);
}

Expand Down
Loading

0 comments on commit 30cd42a

Please sign in to comment.