@river-build/sdk
Version:
For more details, visit the following resources:
361 lines • 14.4 kB
JavaScript
import { PersistedMiniblockSchema, PersistedSyncedStreamSchema, } from '@river-build/proto';
import Dexie from 'dexie';
import { persistedSyncedStreamToParsedSyncedStream, persistedMiniblockToParsedMiniblock, parsedMiniblockToPersistedMiniblock, } from './streamUtils';
import { bin_toHexString, dlog, dlogError } from '@river-build/dlog';
import { isDefined } from './check';
import { isChannelStreamId, isDMChannelStreamId, isGDMChannelStreamId } from './id';
import { fromBinary, toBinary } from '@bufbuild/protobuf';
const MAX_CACHED_SCROLLBACK_COUNT = 3;
const DEFAULT_RETRY_COUNT = 2;
const log = dlog('csb:persistence', { defaultEnabled: false });
const logError = dlogError('csb:persistence:error');
async function fnReadRetryer(fn, retries) {
let lastErr;
let retryCounter = retries;
while (retryCounter > 0) {
try {
if (retryCounter < retries) {
log('retrying...', `${retryCounter}/${retries} retries left`);
retryCounter--;
// wait a bit before retrying
await new Promise((resolve) => setTimeout(resolve, 100));
}
return await fn();
}
catch (err) {
lastErr = err;
const e = err;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
switch (e.name) {
case 'AbortError':
// catch and retry on abort errors
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (e.inner) {
log('AbortError reason:',
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
e.inner, `${retryCounter}/${retries} retries left`);
}
else {
log(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
'AbortError message:' + e.message, `${retryCounter}/${retries} retries left`);
}
break;
default:
// don't retry for unknown errors
logError('Unhandled error:', err);
throw lastErr;
}
}
}
// if we're out of retries, throw the last error
throw lastErr;
}
export class PersistenceStore extends Dexie {
cleartexts;
syncedStreams;
miniblocks;
constructor(databaseName) {
super(databaseName);
this.version(1).stores({
cleartexts: 'eventId',
syncedStreams: 'streamId',
miniblocks: '[streamId+miniblockNum]',
});
// Version 2: added a signature to the saved event, drop all saved miniblocks
this.version(2).upgrade((tx) => {
return tx.table('miniblocks').toCollection().delete();
});
// Version 3: added a signature to the saved event, drop all saved synced streams
this.version(3).upgrade((tx) => {
return tx.table('syncedStreams').toCollection().delete();
});
// Version 4: added a option to have a data_type field to the encrypted data, drop all saved cleartexts
this.version(4).upgrade((tx) => {
return tx.table('cleartexts').toCollection().delete();
});
this.requestPersistentStorage();
this.logPersistenceStats();
}
async saveCleartext(eventId, cleartext) {
await this.cleartexts.put({ eventId, cleartext });
}
async getCleartext(eventId) {
const record = await this.cleartexts.get(eventId);
return record?.cleartext;
}
async getCleartexts(eventIds) {
return fnReadRetryer(async () => {
const records = await this.cleartexts.bulkGet(eventIds);
return records.length === 0
? undefined
: records.reduce((acc, record) => {
if (record !== undefined) {
acc[record.eventId] = record.cleartext;
}
return acc;
}, {});
}, DEFAULT_RETRY_COUNT);
}
async getSyncedStream(streamId) {
const record = await this.syncedStreams.get(streamId);
if (!record) {
return undefined;
}
const cachedSyncedStream = fromBinary(PersistedSyncedStreamSchema, record.data);
const psstpss = persistedSyncedStreamToParsedSyncedStream(streamId, cachedSyncedStream);
return psstpss;
}
async loadStream(streamId, inPersistedSyncedStream) {
const persistedSyncedStream = inPersistedSyncedStream ?? (await this.getSyncedStream(streamId));
if (!persistedSyncedStream) {
return undefined;
}
const miniblocks = await this.getMiniblocks(streamId, persistedSyncedStream.lastSnapshotMiniblockNum, persistedSyncedStream.lastMiniblockNum);
if (miniblocks.length === 0) {
return undefined;
}
const snapshot = miniblocks[0].header.snapshot;
if (!snapshot) {
return undefined;
}
const isChannelStream = isChannelStreamId(streamId) ||
isDMChannelStreamId(streamId) ||
isGDMChannelStreamId(streamId);
const prependedMiniblocks = isChannelStream
? hasTopLevelRenderableEvent(miniblocks)
? []
: await this.cachedScrollback(streamId, miniblocks[0].header.prevSnapshotMiniblockNum, miniblocks[0].header.miniblockNum)
: [];
const snapshotEventIds = eventIdsFromSnapshot(snapshot);
const eventIds = miniblocks.flatMap((mb) => mb.events.map((e) => e.hashStr));
const prependedEventIds = prependedMiniblocks.flatMap((mb) => mb.events.map((e) => e.hashStr));
const cleartexts = await this.getCleartexts([
...eventIds,
...snapshotEventIds,
...prependedEventIds,
]);
return {
persistedSyncedStream,
miniblocks,
cleartexts,
snapshot,
prependedMiniblocks,
prevSnapshotMiniblockNum: miniblocks[0].header.prevSnapshotMiniblockNum,
};
}
async loadStreams(streamIds) {
const result = await this.transaction('r', [this.syncedStreams, this.cleartexts, this.miniblocks], async () => {
const syncedStreams = await this.getSyncedStreams(streamIds);
const retVal = {};
for (const streamId of streamIds) {
retVal[streamId] = await this.loadStream(streamId, syncedStreams[streamId]);
}
return retVal;
});
return result;
}
async getSyncedStreams(streamIds) {
const records = await this.syncedStreams.bulkGet(streamIds);
const cachedSyncedStreams = records.map((x) => x
? { streamId: x.streamId, data: fromBinary(PersistedSyncedStreamSchema, x.data) }
: undefined);
const psstpss = cachedSyncedStreams.reduce((acc, x) => {
if (x) {
acc[x.streamId] = persistedSyncedStreamToParsedSyncedStream(x.streamId, x.data);
}
return acc;
}, {});
return psstpss;
}
async saveSyncedStream(streamId, syncedStream) {
log('saving synced stream', streamId);
await this.syncedStreams.put({
streamId,
data: toBinary(PersistedSyncedStreamSchema, syncedStream),
});
}
async saveMiniblock(streamId, miniblock) {
log('saving miniblock', streamId);
const cachedMiniblock = parsedMiniblockToPersistedMiniblock(miniblock, 'forward');
await this.miniblocks.put({
streamId: streamId,
miniblockNum: miniblock.header.miniblockNum.toString(),
data: toBinary(PersistedMiniblockSchema, cachedMiniblock),
});
}
async saveMiniblocks(streamId, miniblocks, direction) {
await this.miniblocks.bulkPut(miniblocks.map((mb) => {
return {
streamId: streamId,
miniblockNum: mb.header.miniblockNum.toString(),
data: toBinary(PersistedMiniblockSchema, parsedMiniblockToPersistedMiniblock(mb, direction)),
};
}));
}
async getMiniblock(streamId, miniblockNum) {
const record = await this.miniblocks.get([streamId, miniblockNum.toString()]);
if (!record) {
return undefined;
}
const cachedMiniblock = fromBinary(PersistedMiniblockSchema, record.data);
return persistedMiniblockToParsedMiniblock(cachedMiniblock);
}
async getMiniblocks(streamId, rangeStart, rangeEnd) {
const ids = [];
for (let i = rangeStart; i <= rangeEnd; i++) {
ids.push([streamId, i.toString()]);
}
const records = await this.miniblocks.bulkGet(ids);
// All or nothing
const miniblocks = records
.map((record) => {
if (!record) {
return undefined;
}
const cachedMiniblock = fromBinary(PersistedMiniblockSchema, record.data);
return persistedMiniblockToParsedMiniblock(cachedMiniblock);
})
.filter(isDefined);
return miniblocks.length === ids.length ? miniblocks : [];
}
requestPersistentStorage() {
if (typeof navigator !== 'undefined' && navigator.storage && navigator.storage.persist) {
navigator.storage
.persist()
.then((persisted) => {
log('Persisted storage granted: ', persisted);
})
.catch((e) => {
log("Couldn't get persistent storage: ", e);
});
}
else {
log('navigator.storage unavailable');
}
}
logPersistenceStats() {
if (typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate) {
navigator.storage
.estimate()
.then((estimate) => {
const usage = ((estimate.usage ?? 0) / 1024 / 1024).toFixed(1);
const quota = ((estimate.quota ?? 0) / 1024 / 1024).toFixed(1);
log(`Using ${usage} out of ${quota} MB.`);
})
.catch((e) => {
log("Couldn't get storage estimate: ", e);
});
}
else {
log('navigator.storage unavailable');
}
}
async cachedScrollback(streamId, fromInclusive, toExclusive) {
// If this is a channel, DM or GDM, perform a few scrollbacks
if (!isChannelStreamId(streamId) &&
!isDMChannelStreamId(streamId) &&
!isGDMChannelStreamId(streamId)) {
return [];
}
let miniblocks = [];
for (let i = 0; i < MAX_CACHED_SCROLLBACK_COUNT; i++) {
if (toExclusive <= 0n) {
break;
}
const result = await this.getMiniblocks(streamId, fromInclusive, toExclusive - 1n);
if (result.length > 0) {
miniblocks = [...result, ...miniblocks];
fromInclusive = result[0].header.prevSnapshotMiniblockNum;
toExclusive = result[0].header.miniblockNum;
if (hasTopLevelRenderableEvent(miniblocks)) {
break;
}
}
else {
break;
}
}
return miniblocks;
}
}
function hasTopLevelRenderableEvent(miniblocks) {
for (const mb of miniblocks) {
if (topLevelRenderableEventInMiniblock(mb)) {
return true;
}
}
return false;
}
function topLevelRenderableEventInMiniblock(miniblock) {
for (const e of miniblock.events) {
switch (e.event.payload.case) {
case 'channelPayload':
case 'gdmChannelPayload':
case 'dmChannelPayload':
switch (e.event.payload.value.content.case) {
case 'message':
if (!e.event.payload.value.content.value.refEventId) {
return true;
}
}
}
}
return false;
}
function eventIdsFromSnapshot(snapshot) {
const usernameEventIds = snapshot.members?.joined
.filter((m) => isDefined(m.username))
.map((m) => bin_toHexString(m.username.eventHash)) ?? [];
const displayNameEventIds = snapshot.members?.joined
.filter((m) => isDefined(m.displayName))
.map((m) => bin_toHexString(m.displayName.eventHash)) ?? [];
switch (snapshot.content.case) {
case 'gdmChannelContent': {
const channelPropertiesEventIds = snapshot.content.value.channelProperties
? [bin_toHexString(snapshot.content.value.channelProperties.eventHash)]
: [];
return [...usernameEventIds, ...displayNameEventIds, ...channelPropertiesEventIds];
}
default:
return [...usernameEventIds, ...displayNameEventIds];
}
}
//Linting below is disable as this is a stub class which is used for testing and just follows the interface
/* eslint-disable @typescript-eslint/no-unused-vars */
export class StubPersistenceStore {
async saveCleartext(eventId, cleartext) {
return Promise.resolve();
}
async getCleartext(eventId) {
return Promise.resolve(undefined);
}
async getCleartexts(eventIds) {
return Promise.resolve(undefined);
}
async getSyncedStream(streamId) {
return Promise.resolve(undefined);
}
async loadStream(streamId, inPersistedSyncedStream) {
return Promise.resolve(undefined);
}
async loadStreams(streamIds) {
return Promise.resolve({});
}
async saveSyncedStream(streamId, syncedStream) {
return Promise.resolve();
}
async saveMiniblock(streamId, miniblock) {
return Promise.resolve();
}
async saveMiniblocks(streamId, miniblocks, direction) {
return Promise.resolve();
}
async getMiniblock(streamId, miniblockNum) {
return Promise.resolve(undefined);
}
async getMiniblocks(streamId, rangeStart, rangeEnd) {
return Promise.resolve([]);
}
}
//# sourceMappingURL=persistenceStore.js.map