@river-build/sdk
Version:
For more details, visit the following resources:
527 lines • 25.2 kB
JavaScript
import { dlog, dlogError, bin_toHexString, check } from '@river-build/dlog';
import { isDefined, logNever } from './check';
import { Err, } from '@river-build/proto';
import { isConfirmedEvent, isDecryptedEvent, isLocalEvent, makeRemoteTimelineEvent, } from './types';
import { StreamStateView_Space } from './streamStateView_Space';
import { StreamStateView_Channel } from './streamStateView_Channel';
import { StreamStateView_User } from './streamStateView_User';
import { StreamStateView_UserSettings } from './streamStateView_UserSettings';
import { StreamStateView_UserMetadata } from './streamStateView_UserMetadata';
import { StreamStateView_Members } from './streamStateView_Members';
import { StreamStateView_Media } from './streamStateView_Media';
import { StreamStateView_GDMChannel } from './streamStateView_GDMChannel';
import { StreamStateView_DMChannel } from './streamStateView_DMChannel';
import { genLocalId, isChannelStreamId, isDMChannelStreamId, isGDMChannelStreamId, isMediaStreamId, isSpaceStreamId, isUserDeviceStreamId, isUserSettingsStreamId, isUserStreamId, isUserInboxStreamId, } from './id';
import { StreamStateView_UserInbox } from './streamStateView_UserInbox';
import { StreamStateView_UnknownContent } from './streamStateView_UnknownContent';
import isEqual from 'lodash/isEqual';
import { migrateSnapshot } from './migrations/migrateSnapshot';
const log = dlog('csb:streams');
const logError = dlogError('csb:streams:error');
export class StreamStateView {
streamId;
userId;
contentKind;
timeline = [];
events = new Map();
isInitialized = false;
prevMiniblockHash;
lastEventNum = 0n;
prevSnapshotMiniblockNum;
miniblockInfo;
syncCookie;
saveSnapshots;
_snapshot;
snapshot() {
check(this.saveSnapshots === true, 'snapshots are not enabled');
return this._snapshot;
}
// membership content
membershipContent;
// Space Content
_spaceContent;
get spaceContent() {
check(isDefined(this._spaceContent), `spaceContent not defined for ${this.contentKind}`);
return this._spaceContent;
}
// Channel Content
_channelContent;
get channelContent() {
check(isDefined(this._channelContent), `channelContent not defined for ${this.contentKind}`);
return this._channelContent;
}
// DM Channel Content
_dmChannelContent;
get dmChannelContent() {
check(isDefined(this._dmChannelContent), `dmChannelContent not defined for ${this.contentKind}`);
return this._dmChannelContent;
}
// GDM Channel Content
_gdmChannelContent;
get gdmChannelContent() {
check(isDefined(this._gdmChannelContent), `gdmChannelContent not defined for ${this.contentKind}`);
return this._gdmChannelContent;
}
// User Content
_userContent;
get userContent() {
check(isDefined(this._userContent), `userContent not defined for ${this.contentKind}`);
return this._userContent;
}
// User Settings Content
_userSettingsContent;
get userSettingsContent() {
check(isDefined(this._userSettingsContent), `userSettingsContent not defined for ${this.contentKind}`);
return this._userSettingsContent;
}
_userMetadataContent;
get userMetadataContent() {
check(isDefined(this._userMetadataContent), `userMetadataContent not defined for ${this.contentKind}`);
return this._userMetadataContent;
}
_userInboxContent;
get userInboxContent() {
check(isDefined(this._userInboxContent), `userInboxContent not defined for ${this.contentKind}`);
return this._userInboxContent;
}
_mediaContent;
get mediaContent() {
check(isDefined(this._mediaContent), `mediaContent not defined for ${this.contentKind}`);
return this._mediaContent;
}
constructor(userId, streamId) {
log('streamStateView::constructor', streamId);
this.userId = userId;
this.streamId = streamId;
if (isSpaceStreamId(streamId)) {
this.contentKind = 'spaceContent';
this._spaceContent = new StreamStateView_Space(streamId);
}
else if (isChannelStreamId(streamId)) {
this.contentKind = 'channelContent';
this._channelContent = new StreamStateView_Channel(streamId);
}
else if (isDMChannelStreamId(streamId)) {
this.contentKind = 'dmChannelContent';
this._dmChannelContent = new StreamStateView_DMChannel(streamId);
}
else if (isGDMChannelStreamId(streamId)) {
this.contentKind = 'gdmChannelContent';
this._gdmChannelContent = new StreamStateView_GDMChannel(streamId);
}
else if (isMediaStreamId(streamId)) {
this.contentKind = 'mediaContent';
this._mediaContent = new StreamStateView_Media(streamId);
}
else if (isUserStreamId(streamId)) {
this.contentKind = 'userContent';
this._userContent = new StreamStateView_User(streamId);
}
else if (isUserSettingsStreamId(streamId)) {
this.contentKind = 'userSettingsContent';
this._userSettingsContent = new StreamStateView_UserSettings(streamId);
}
else if (isUserDeviceStreamId(streamId)) {
this.contentKind = 'userMetadataContent';
this._userMetadataContent = new StreamStateView_UserMetadata(streamId);
}
else if (isUserInboxStreamId(streamId)) {
this.contentKind = 'userInboxContent';
this._userInboxContent = new StreamStateView_UserInbox(streamId);
}
else {
throw new Error(`Stream doesn't have a content kind ${streamId}`);
}
this.prevSnapshotMiniblockNum = 0n;
this.membershipContent = new StreamStateView_Members(streamId);
}
applySnapshot(eventHash, event, inSnapshot, cleartexts, encryptionEmitter) {
const snapshot = migrateSnapshot(inSnapshot);
switch (snapshot.content.case) {
case 'spaceContent':
this.spaceContent.applySnapshot(eventHash, snapshot, snapshot.content.value, cleartexts, encryptionEmitter);
break;
case 'channelContent':
this.channelContent.applySnapshot(snapshot, snapshot.content.value, cleartexts, encryptionEmitter);
break;
case 'dmChannelContent':
this.dmChannelContent.applySnapshot(snapshot, snapshot.content.value, cleartexts, encryptionEmitter);
break;
case 'gdmChannelContent':
this.gdmChannelContent.applySnapshot(snapshot, snapshot.content.value, cleartexts, encryptionEmitter);
break;
case 'mediaContent':
this.mediaContent.applySnapshot(snapshot, snapshot.content.value, encryptionEmitter);
break;
case 'userContent':
this.userContent.applySnapshot(snapshot, snapshot.content.value, encryptionEmitter);
break;
case 'userMetadataContent':
this.userMetadataContent.applySnapshot(snapshot, snapshot.content.value, encryptionEmitter);
break;
case 'userSettingsContent':
this.userSettingsContent.applySnapshot(snapshot, snapshot.content.value);
break;
case 'userInboxContent':
this.userInboxContent.applySnapshot(snapshot, snapshot.content.value, encryptionEmitter);
break;
case undefined:
check(false, `Snapshot has no content ${this.streamId}`, Err.STREAM_BAD_EVENT);
break;
default:
logNever(snapshot.content);
}
this.membershipContent.applySnapshot(eventHash, event, snapshot, cleartexts, encryptionEmitter);
}
appendStreamAndCookie(nextSyncCookie, minipoolEvents, cleartexts, encryptionEmitter, stateEmitter) {
const appended = [];
const updated = [];
const confirmed = [];
for (const parsedEvent of minipoolEvents) {
const existingEvent = this.events.get(parsedEvent.hashStr);
if (existingEvent) {
existingEvent.remoteEvent = parsedEvent;
updated.push(existingEvent);
}
else {
const event = makeRemoteTimelineEvent({
parsedEvent,
eventNum: this.lastEventNum++,
miniblockNum: undefined,
confirmedEventNum: undefined,
});
if (event.remoteEvent.event.payload.case !== 'miniblockHeader') {
this.timeline.push(event);
}
const newlyConfirmed = this.processAppendedEvent(event, cleartexts?.[event.hashStr], encryptionEmitter, stateEmitter);
appended.push(event);
if (newlyConfirmed) {
confirmed.push(...newlyConfirmed);
}
}
}
this.syncCookie = nextSyncCookie;
return { appended, updated, confirmed };
}
processAppendedEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter) {
check(!this.events.has(timelineEvent.hashStr));
if (timelineEvent.remoteEvent.event.payload.case !== 'miniblockHeader') {
this.events.set(timelineEvent.hashStr, timelineEvent);
}
const event = timelineEvent.remoteEvent;
const payload = event.event.payload;
check(isDefined(payload), `Event has no payload ${event.hashStr}`, Err.STREAM_BAD_EVENT);
let confirmed = undefined;
try {
switch (payload.case) {
case 'miniblockHeader':
check((this.miniblockInfo?.max ?? -1n) < payload.value.miniblockNum, `Miniblock number out of order ${payload.value.miniblockNum} > ${this.miniblockInfo?.max}`, Err.STREAM_BAD_EVENT);
if (this.saveSnapshots && payload.value.snapshot) {
this._snapshot = payload.value.snapshot;
}
this.prevMiniblockHash = event.hash;
this.updateMiniblockInfo(payload.value, { max: payload.value.miniblockNum });
timelineEvent.confirmedEventNum =
payload.value.eventNumOffset + BigInt(payload.value.eventHashes.length);
timelineEvent.miniblockNum = payload.value.miniblockNum;
confirmed = this.processMiniblockHeader(payload.value, payload.value.eventHashes, encryptionEmitter, stateEmitter);
break;
case 'memberPayload':
this.membershipContent.appendEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter);
break;
case undefined:
break;
default:
this.getContent().appendEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter);
}
}
catch (e) {
logError(`StreamStateView::Error appending streamId: ${this.streamId} event ${event.hashStr}`, e);
}
return confirmed;
}
processMiniblockHeader(header, eventHashes, encryptionEmitter, stateEmitter) {
const confirmed = [];
for (let i = 0; i < eventHashes.length; i++) {
const eventId = bin_toHexString(eventHashes[i]);
const event = this.events.get(eventId);
if (!event) {
logError(`Mininblock event not found ${eventId}`); // aellis this is pretty serious
continue;
}
event.miniblockNum = header.miniblockNum;
event.confirmedEventNum = header.eventNumOffset + BigInt(i);
check(isConfirmedEvent(event), `Event is not confirmed ${eventId}`);
switch (event.remoteEvent.event.payload.case) {
case 'memberPayload':
this.membershipContent.onConfirmedEvent(event, stateEmitter, encryptionEmitter);
break;
case undefined:
break;
default:
this.getContent().onConfirmedEvent(event, stateEmitter, encryptionEmitter);
}
confirmed.push(event);
}
return confirmed;
}
processPrependedEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter) {
check(!this.events.has(timelineEvent.hashStr));
if (timelineEvent.remoteEvent.event.payload.case !== 'miniblockHeader') {
this.events.set(timelineEvent.hashStr, timelineEvent);
}
const event = timelineEvent.remoteEvent;
const payload = event.event.payload;
check(isDefined(payload), `Event has no payload ${event.hashStr}`, Err.STREAM_BAD_EVENT);
try {
switch (payload.case) {
case 'miniblockHeader':
this.updateMiniblockInfo(payload.value, { min: payload.value.miniblockNum });
this.prevSnapshotMiniblockNum = payload.value.prevSnapshotMiniblockNum;
break;
case 'memberPayload':
this.membershipContent.prependEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter);
break;
case undefined:
logError(`StreamStateView::Error undefined payload case ${event.hashStr}`, payload);
break;
default:
this.getContent().prependEvent(timelineEvent, cleartext, encryptionEmitter, stateEmitter);
}
}
catch (e) {
logError(`StreamStateView::Error prepending stream ${this.streamId} event ${event.hashStr}`, e);
}
}
updateMiniblockInfo(value, update) {
if (!this.miniblockInfo) {
this.miniblockInfo = {
max: value.miniblockNum,
min: value.miniblockNum,
terminusReached: value.miniblockNum === 0n,
};
return;
}
if (update.max && update.max > this.miniblockInfo.max) {
this.miniblockInfo.max = update.max;
}
if (update.min && update.min < this.miniblockInfo.min) {
this.miniblockInfo.min = update.min;
}
if (this.miniblockInfo.min === 0n || this.miniblockInfo.max === 0n) {
this.miniblockInfo.terminusReached = true;
}
}
// update streeam state with successfully decrypted events by hashStr event id
updateDecryptedContent(eventId, content, emitter) {
this.membershipContent.onDecryptedContent(eventId, content, emitter);
this.getContent().onDecryptedContent(eventId, content, emitter);
const timelineEvent = this.events.get(eventId);
if (timelineEvent) {
if (timelineEvent.decryptedContent !== undefined) {
logError(`timeline event was decrypted twice? ${eventId}`);
}
timelineEvent.decryptedContent = content;
check(isDecryptedEvent(timelineEvent), `Event is not decrypted, programmer error ${eventId}`);
emitter.emit('streamUpdated', this.streamId, this.contentKind, {
updated: [timelineEvent],
});
// dispatching eventDecrypted makes it easier to test
emitter.emit('eventDecrypted', this.streamId, this.contentKind, timelineEvent);
}
}
// update stream with decryption status
updateDecryptedContentError(eventId, content, emitter) {
const timelineEvent = this.events.get(eventId);
if (timelineEvent && !isEqual(timelineEvent.decryptedContentError, content)) {
check(timelineEvent.decryptedContent === undefined, 'Event is already decrypted');
timelineEvent.decryptedContentError = content;
emitter.emit('streamUpdated', this.streamId, this.contentKind, {
updated: [timelineEvent],
});
}
}
initialize(nextSyncCookie, minipoolEvents, snapshot, miniblocks, prependedMiniblocks, prevSnapshotMiniblockNum, cleartexts, localEvents, emitter) {
check(miniblocks.length > 0, `Stream has no miniblocks ${this.streamId}`, Err.STREAM_EMPTY);
// parse the blocks
const miniblockHeaderEvent = miniblocks[0].events.at(-1);
check(isDefined(miniblockHeaderEvent), `Miniblock header event not found ${this.streamId}`, Err.STREAM_EMPTY);
// initialize from snapshot data, this gets all memberships and channel data, etc
this.applySnapshot(bin_toHexString(miniblocks[0].hash), miniblockHeaderEvent, snapshot, cleartexts, emitter);
// initialize from miniblocks, the first minblock is the snapshot block, it's events are accounted for
const block0Events = miniblocks[0].events.map((parsedEvent, i) => {
const eventNum = miniblocks[0].header.eventNumOffset + BigInt(i);
return makeRemoteTimelineEvent({
parsedEvent,
eventNum,
miniblockNum: miniblocks[0].header.miniblockNum,
confirmedEventNum: eventNum,
});
});
// the rest need to be added to the timeline
const rest = miniblocks.slice(1).flatMap((mb) => mb.events.map((parsedEvent, i) => {
const eventNum = mb.header.eventNumOffset + BigInt(i);
return makeRemoteTimelineEvent({
parsedEvent,
eventNum,
miniblockNum: mb.header.miniblockNum,
confirmedEventNum: eventNum,
});
}));
// initialize our event hashes
check(block0Events.length > 0);
// prepend the snapshotted block in reverse order
this.timeline.push(...block0Events.filter((e) => e.remoteEvent.event.payload.case !== 'miniblockHeader'));
for (let i = block0Events.length - 1; i >= 0; i--) {
const event = block0Events[i];
this.processPrependedEvent(event, cleartexts?.[event.hashStr], emitter, undefined);
}
// append the new block events
this.timeline.push(...rest.filter((e) => e.remoteEvent.event.payload.case !== 'miniblockHeader'));
for (const event of rest) {
this.processAppendedEvent(event, cleartexts?.[event.hashStr], emitter, undefined);
}
// initialize the lastEventNum
const lastBlock = miniblocks[miniblocks.length - 1];
this.lastEventNum = lastBlock.header.eventNumOffset + BigInt(lastBlock.events.length);
// and the prev miniblock has (if there were more than 1 miniblocks, this should already be set)
this.prevMiniblockHash = lastBlock.hash;
// append the minipool events
this.appendStreamAndCookie(nextSyncCookie, minipoolEvents, cleartexts, emitter, undefined);
this.prevSnapshotMiniblockNum = prevSnapshotMiniblockNum;
if (prependedMiniblocks.length > 0) {
this.prependEvents(prependedMiniblocks, cleartexts, prependedMiniblocks[0].header.miniblockNum === 0n, emitter, undefined);
}
for (const localEvent of localEvents) {
localEvent.eventNum = this.lastEventNum++;
this.events.set(localEvent.hashStr, localEvent);
this.timeline.push(localEvent);
this.getContent().onAppendLocalEvent(localEvent, emitter);
}
// let everyone know
this.isInitialized = true;
emitter?.emit('streamInitialized', this.streamId, this.contentKind);
}
appendEvents(events, nextSyncCookie, cleartexts, emitter) {
const { appended, updated, confirmed } = this.appendStreamAndCookie(nextSyncCookie, events, cleartexts, emitter, emitter);
emitter?.emit('streamUpdated', this.streamId, this.contentKind, {
appended: appended.length > 0 ? appended : undefined,
updated: updated.length > 0 ? updated : undefined,
confirmed: confirmed.length > 0 ? confirmed : undefined,
});
}
prependEvents(miniblocks, cleartexts, terminus, encryptionEmitter, stateEmitter) {
const prependedFull = miniblocks.flatMap((mb) => mb.events.map((parsedEvent, i) => makeRemoteTimelineEvent({
parsedEvent,
eventNum: mb.header.eventNumOffset + BigInt(i),
miniblockNum: mb.header.miniblockNum,
confirmedEventNum: mb.header.eventNumOffset + BigInt(i),
})));
// aellis 11/23 I don't know why we're getting dupes on scrollback,
// but this prevents us from throwing an error
const prepended = prependedFull.filter((e) => !this.events.has(e.hashStr));
if (prepended.length !== prependedFull.length) {
logError('StreamStateView::prependEvents: duplicate events found', {
dupes: prependedFull
.filter((e) => this.events.has(e.hashStr))
.map((e) => e.hashStr),
});
}
this.timeline.unshift(...prepended.filter((e) => e.remoteEvent.event.payload.case !== 'miniblockHeader'));
// prepend the new block events in reverse order
for (let i = prepended.length - 1; i >= 0; i--) {
const event = prepended[i];
this.processPrependedEvent(event, cleartexts?.[event.hashStr], encryptionEmitter, stateEmitter);
}
if (this.miniblockInfo && terminus) {
this.miniblockInfo.terminusReached = true;
}
stateEmitter?.emit('streamUpdated', this.streamId, this.contentKind, { prepended });
}
appendLocalEvent(channelMessage, status, emitter) {
const localId = genLocalId();
log('appendLocalEvent', localId);
const timelineEvent = {
hashStr: localId,
creatorUserId: this.userId,
eventNum: this.lastEventNum++,
localEvent: { localId, channelMessage, status },
createdAtEpochMs: BigInt(Date.now()),
};
this.events.set(localId, timelineEvent);
this.timeline.push(timelineEvent);
this.getContent().onAppendLocalEvent(timelineEvent, emitter);
emitter?.emit('streamUpdated', this.streamId, this.contentKind, {
appended: [timelineEvent],
});
return localId;
}
updateLocalEvent(localId, parsedEventHash, status, emitter) {
log('updateLocalEvent', { localId, parsedEventHash, status });
const timelineEvent = this.events.get(localId);
check(isDefined(timelineEvent), `Local event not found ${localId}`);
check(isLocalEvent(timelineEvent), `Event is not local ${localId}`);
const previousId = timelineEvent.hashStr;
timelineEvent.hashStr = parsedEventHash;
timelineEvent.localEvent.status = status;
this.events.set(parsedEventHash, timelineEvent);
emitter?.emit('streamLocalEventUpdated', this.streamId, this.contentKind, previousId, timelineEvent);
}
getMembers() {
return this.membershipContent;
}
getMemberMetadata() {
return this.membershipContent.memberMetadata;
}
getChannelMetadata() {
return this.getContent().getChannelMetadata();
}
getContent() {
switch (this.contentKind) {
case 'channelContent':
return this.channelContent;
case 'dmChannelContent':
return this.dmChannelContent;
case 'gdmChannelContent':
return this.gdmChannelContent;
case 'spaceContent':
return this.spaceContent;
case 'userContent':
return this.userContent;
case 'userSettingsContent':
return this.userSettingsContent;
case 'userMetadataContent':
return this.userMetadataContent;
case 'userInboxContent':
return this.userInboxContent;
case 'mediaContent':
return this.mediaContent;
case undefined:
throw new Error('Stream has no content');
default:
logNever(this.contentKind);
return new StreamStateView_UnknownContent(this.streamId);
}
}
/**
* Streams behave slightly differently.
* Regular channels: the user needs to be an active member. SO_JOIN
* DMs: always open for key exchange for any of the two participants
*/
userIsEntitledToKeyExchange(userId) {
return this.getUsersEntitledToKeyExchange().has(userId);
}
getUsersEntitledToKeyExchange() {
switch (this.contentKind) {
case 'channelContent':
case 'spaceContent':
case 'gdmChannelContent':
return this.getMembers().joinedParticipants();
case 'dmChannelContent':
return this.getMembers().participants(); // send keys to all participants
default:
return new Set();
}
}
}
//# sourceMappingURL=streamStateView.js.map