UNPKG

@river-build/sdk

Version:

For more details, visit the following resources:

361 lines 14.4 kB
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