Merge branch 'snowpack-ts-storage-3' into snowpack-ts-storage-4

This commit is contained in:
Danila Fedorin 2021-08-31 15:26:26 -07:00
commit 7f8089eaff
32 changed files with 485 additions and 255 deletions

View file

@ -37,3 +37,5 @@ There are no npm modules yet published for Hydrogen. The easiest is probably to
For example, for a single room chat, you could create an instance of `Platform`, you create a new `SessionContainer` with it, call `startWithLogin` on it, observe `sessionContainer.loadStatus` to know when initial sync is done, then do `sessionContainer.session.rooms.get('roomid')` and you create a `RoomViewModel` with it and pass that to a `RoomView`. Then you call `document.appendChild(roomView.mount())` and you should see a syncing room. For example, for a single room chat, you could create an instance of `Platform`, you create a new `SessionContainer` with it, call `startWithLogin` on it, observe `sessionContainer.loadStatus` to know when initial sync is done, then do `sessionContainer.session.rooms.get('roomid')` and you create a `RoomViewModel` with it and pass that to a `RoomView`. Then you call `document.appendChild(roomView.mount())` and you should see a syncing room.
Feel free to ask for pointers in #hydrogen:matrix.org as the documentation is still lacking considerably. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet. Feel free to ask for pointers in #hydrogen:matrix.org as the documentation is still lacking considerably. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet.
Also, to make end-to-end encryption work, you'll likely need some tweaks to your build system, see [this issue](https://github.com/vector-im/hydrogen-web/issues/467).

View file

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.7", "version": "0.2.8",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"main": "index.js", "main": "index.js",
"directories": { "directories": {

View file

@ -204,7 +204,7 @@ export class SessionContainer {
reconnector: this._reconnector, reconnector: this._reconnector,
}); });
this._sessionId = sessionInfo.id; this._sessionId = sessionInfo.id;
this._storage = await this._platform.storageFactory.create(sessionInfo.id); this._storage = await this._platform.storageFactory.create(sessionInfo.id, log);
// no need to pass access token to session // no need to pass access token to session
const filteredSessionInfo = { const filteredSessionInfo = {
id: sessionInfo.id, id: sessionInfo.id,

View file

@ -19,6 +19,22 @@ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_OUTDATED = 0;
const TRACKING_STATUS_UPTODATE = 1; const TRACKING_STATUS_UPTODATE = 1;
export function addRoomToIdentity(identity, userId, roomId) {
if (!identity) {
identity = {
userId: userId,
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
};
return identity;
} else {
if (!identity.roomIds.includes(roomId)) {
identity.roomIds.push(roomId);
return identity;
}
}
}
// map 1 device from /keys/query response to DeviceIdentity // map 1 device from /keys/query response to DeviceIdentity
function deviceKeysAsDeviceIdentity(deviceSection) { function deviceKeysAsDeviceIdentity(deviceSection) {
const deviceId = deviceSection["device_id"]; const deviceId = deviceSection["device_id"];
@ -107,17 +123,9 @@ export class DeviceTracker {
async _writeMember(member, txn) { async _writeMember(member, txn) {
const {userIdentities} = txn; const {userIdentities} = txn;
const identity = await userIdentities.get(member.userId); const identity = await userIdentities.get(member.userId);
if (!identity) { const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
userIdentities.set({ if (updatedIdentity) {
userId: member.userId, userIdentities.set(updatedIdentity);
roomIds: [member.roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
});
} else {
if (!identity.roomIds.includes(member.roomId)) {
identity.roomIds.push(member.roomId);
userIdentities.set(identity);
}
} }
} }

View file

@ -30,6 +30,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends BaseRoom { export class Room extends BaseRoom {
constructor(options) { constructor(options) {
super(options); super(options);
// TODO: pass pendingEvents to start like pendingOperations?
const {pendingEvents} = options; const {pendingEvents} = options;
const relationWriter = new RelationWriter({ const relationWriter = new RelationWriter({
roomId: this.id, roomId: this.id,
@ -120,7 +121,8 @@ export class Room extends BaseRoom {
txn.roomMembers.removeAllForRoom(this.id); txn.roomMembers.removeAllForRoom(this.id);
} }
const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} = const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} =
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); await log.wrap("syncWriter", log => this._syncWriter.writeSync(
roomResponse, isRejoin, summaryChanges.hasFetchedMembers, txn, log), log.level.Detail);
if (decryptChanges) { if (decryptChanges) {
const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log));
log.set("decryptionResults", decryption.results.size); log.set("decryptionResults", decryption.results.size);

View file

@ -141,6 +141,16 @@ export class MemberChange {
return this.previousMembership === "join" && this.membership !== "join"; return this.previousMembership === "join" && this.membership !== "join";
} }
/** The result can be a false negative when all of these apply:
* - the complete set of room members hasn't been fetched yet.
* - the member event for this change was received in the
* state section and wasn't present in the timeline section.
* - the room response was limited, e.g. there was a gap.
*
* This is because during sync, in this case it is not possible
* to distinguish between a new member that joined the room
* during a gap and a lazy-loading member.
* */
get hasJoined() { get hasJoined() {
return this.previousMembership !== "join" && this.membership === "join"; return this.previousMembership !== "join" && this.membership === "join";
} }

View file

@ -23,57 +23,27 @@ export class MemberWriter {
this._cache = new LRUCache(5, member => member.userId); this._cache = new LRUCache(5, member => member.userId);
} }
writeTimelineMemberEvent(event, txn) { prepareMemberSync(stateEvents, timelineEvents, hasFetchedMembers) {
return this._writeMemberEvent(event, false, txn); return new MemberSync(this, stateEvents, timelineEvents, hasFetchedMembers);
} }
writeStateMemberEvent(event, isLimited, txn) { async _writeMember(member, txn) {
// member events in the state section when the room response let existingMember = this._cache.get(member.userId);
// is not limited must always be lazy loaded members.
// If they are not, they will be repeated in the timeline anyway.
return this._writeMemberEvent(event, !isLimited, txn);
}
async _writeMemberEvent(event, isLazyLoadingMember, txn) {
const userId = event.state_key;
if (!userId) {
return;
}
const member = RoomMember.fromMemberEvent(this._roomId, event);
if (!member) {
return;
}
let existingMember = this._cache.get(userId);
if (!existingMember) { if (!existingMember) {
const memberData = await txn.roomMembers.get(this._roomId, userId); const memberData = await txn.roomMembers.get(this._roomId, member.userId);
if (memberData) { if (memberData) {
existingMember = new RoomMember(memberData); existingMember = new RoomMember(memberData);
} }
} }
// either never heard of the member, or something changed // either never heard of the member, or something changed
if (!existingMember || !existingMember.equals(member)) { if (!existingMember || !existingMember.equals(member)) {
txn.roomMembers.set(member.serialize()); txn.roomMembers.set(member.serialize());
this._cache.set(member); this._cache.set(member);
// we also return a member change for lazy loading members if something changed,
// so when the dupe timeline event comes and it doesn't see a diff
// with the cache, we already returned the event here.
//
// it's just important that we don't consider the first LL event
// for a user we see as a membership change, or we'll share keys with
// them, etc...
if (isLazyLoadingMember && !existingMember) {
// we don't have a previous member, but we know this is not a
// membership change as it's a lazy loaded
// member so take the membership from the member
return new MemberChange(member, member.membership);
}
return new MemberChange(member, existingMember?.membership); return new MemberChange(member, existingMember?.membership);
} }
} }
async lookupMember(userId, event, timelineEvents, txn) { async lookupMember(userId, txn) {
let member = this._cache.get(userId); let member = this._cache.get(userId);
if (!member) { if (!member) {
const memberData = await txn.roomMembers.get(this._roomId, userId); const memberData = await txn.roomMembers.get(this._roomId, userId);
@ -82,61 +52,154 @@ export class MemberWriter {
this._cache.set(member); this._cache.set(member);
} }
} }
if (!member) { return member;
// sometimes the member event isn't included in state, but rather in the timeline, }
// even if it is not the first event in the timeline. In this case, go look for }
// the last one before the event, or if none is found,
// the least recent matching member event in the timeline. class MemberSync {
// The latter is needed because of new joins picking up their own display name constructor(memberWriter, stateEvents, timelineEvents, hasFetchedMembers) {
let foundEvent = false; this._memberWriter = memberWriter;
let memberEventBefore; this._timelineEvents = timelineEvents;
let firstMemberEvent; this._hasFetchedMembers = hasFetchedMembers;
for (let i = timelineEvents.length - 1; i >= 0; i -= 1) { this._newStateMembers = null;
if (stateEvents) {
this._newStateMembers = this._stateEventsToMembers(stateEvents);
}
}
get _roomId() {
return this._memberWriter._roomId;
}
_stateEventsToMembers(stateEvents) {
let members;
for (const event of stateEvents) {
if (event.type === MEMBER_EVENT_TYPE) {
const member = RoomMember.fromMemberEvent(this._roomId, event);
if (member) {
if (!members) {
members = new Map();
}
members.set(member.userId, member);
}
}
}
return members;
}
_timelineEventsToMembers(timelineEvents) {
let members;
// iterate backwards to only add the last member in the timeline
for (let i = timelineEvents.length - 1; i >= 0; i--) {
const e = timelineEvents[i]; const e = timelineEvents[i];
let matchingEvent; const userId = e.state_key;
if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { if (e.type === MEMBER_EVENT_TYPE && !members?.has(userId)) {
matchingEvent = e; const member = RoomMember.fromMemberEvent(this._roomId, e);
firstMemberEvent = matchingEvent; if (member) {
if (!members) {
members = new Map();
} }
if (!foundEvent) { members.set(member.userId, member);
}
}
}
return members;
}
async lookupMemberAtEvent(userId, event, txn) {
let member;
if (this._timelineEvents) {
member = this._findPrecedingMemberEventInTimeline(userId, event);
if (member) {
return member;
}
}
member = this._newStateMembers?.get(userId);
if (member) {
return member;
}
return await this._memberWriter.lookupMember(userId, txn);
}
async write(txn) {
const memberChanges = new Map();
let newTimelineMembers;
if (this._timelineEvents) {
newTimelineMembers = this._timelineEventsToMembers(this._timelineEvents);
}
if (this._newStateMembers) {
for (const member of this._newStateMembers.values()) {
if (!newTimelineMembers?.has(member.userId)) {
const memberChange = await this._memberWriter._writeMember(member, txn);
if (memberChange) {
// if the member event appeared only in the state section,
// AND we haven't heard about it AND we haven't fetched all members yet (to avoid #470),
// this may be a lazy loading member (if it's not in a gap, we are certain
// it is a ll member, in a gap, we can't tell), so we pass in our own membership as
// as the previous one so we won't consider it a join to not have false positives (to avoid #192).
// see also MemberChange.hasJoined
const maybeLazyLoadingMember = !this._hasFetchedMembers && !memberChange.previousMembership;
if (maybeLazyLoadingMember) {
memberChange.previousMembership = member.membership;
}
memberChanges.set(memberChange.userId, memberChange);
}
}
}
}
if (newTimelineMembers) {
for (const member of newTimelineMembers.values()) {
const memberChange = await this._memberWriter._writeMember(member, txn);
if (memberChange) {
memberChanges.set(memberChange.userId, memberChange);
}
}
}
return memberChanges;
}
// try to find the first member event before the given event,
// so we respect historical display names within the chunk of timeline
_findPrecedingMemberEventInTimeline(userId, event) {
let eventIndex = -1;
for (let i = this._timelineEvents.length - 1; i >= 0; i--) {
const e = this._timelineEvents[i];
if (e.event_id === event.event_id) { if (e.event_id === event.event_id) {
foundEvent = true; eventIndex = i;
}
} else if (matchingEvent) {
memberEventBefore = matchingEvent;
break; break;
} }
} }
// first see if we found a member event before the event we're looking up the sender for for (let i = eventIndex - 1; i >= 0; i--) {
if (memberEventBefore) { const e = this._timelineEvents[i];
member = RoomMember.fromMemberEvent(this._roomId, memberEventBefore); if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) {
} const member = RoomMember.fromMemberEvent(this._roomId, e);
// and only if we didn't, fall back to the first member event, if (member) {
// regardless of where it is positioned relative to the lookup event
else if (firstMemberEvent) {
member = RoomMember.fromMemberEvent(this._roomId, firstMemberEvent);
}
}
return member; return member;
} }
} }
}
}
}
export function tests() { export function tests() {
let idCounter = 0;
function createMemberEvent(membership, userId, displayName, avatarUrl) { function createMemberEvent(membership, userId, displayName, avatarUrl) {
idCounter += 1;
return { return {
content: { content: {
membership, membership,
"displayname": displayName, "displayname": displayName,
"avatar_url": avatarUrl "avatar_url": avatarUrl
}, },
event_id: `$${idCounter}`,
sender: userId, sender: userId,
"state_key": userId, "state_key": userId,
type: "m.room.member" type: "m.room.member"
}; };
} }
function createStorage(initialMembers = []) { function createStorage(initialMembers = []) {
const members = new Map(); const members = new Map();
for (const m of initialMembers) { for (const m of initialMembers) {
@ -164,102 +227,195 @@ export function tests() {
const avatar = "mxc://hs.tld/def"; const avatar = "mxc://hs.tld/def";
return { return {
"new join through state": async assert => { "new join": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage(); const txn = createStorage();
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice)], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasJoined); assert(change.hasJoined);
assert.equal(txn.members.get(alice).membership, "join"); assert.equal(txn.members.get(alice).membership, "join");
}, },
"accept invite through state": async assert => { "accept invite": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("invite", alice)]); const txn = createStorage([member("invite", alice)]);
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice)], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert.equal(change.previousMembership, "invite"); assert.equal(change.previousMembership, "invite");
assert(change.hasJoined); assert(change.hasJoined);
assert.equal(txn.members.get(alice).membership, "join"); assert.equal(txn.members.get(alice).membership, "join");
}, },
"change display name through timeline": async assert => { "change display name": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alies"), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alies")], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(!change.hasJoined); assert(!change.hasJoined);
assert.equal(change.member.displayName, "Alies"); assert.equal(change.member.displayName, "Alies");
assert.equal(txn.members.get(alice).displayName, "Alies"); assert.equal(txn.members.get(alice).displayName, "Alies");
}, },
"set avatar through timeline": async assert => { "set avatar": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alice", avatar)], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(!change.hasJoined); assert(!change.hasJoined);
assert.equal(change.member.avatarUrl, avatar); assert.equal(change.member.avatarUrl, avatar);
assert.equal(txn.members.get(alice).avatarUrl, avatar); assert.equal(txn.members.get(alice).avatarUrl, avatar);
}, },
"ignore redundant member event": async assert => { "ignore redundant member event in timeline": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice", avatar)]); const txn = createStorage([member("join", alice, "Alice", avatar)]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alice", avatar)], false);
assert(!change); const changes = await memberSync.write(txn);
assert.equal(changes.size, 0);
},
"ignore redundant member event in state": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice", avatar)]);
const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice", avatar)], [], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 0);
}, },
"leave": async assert => { "leave": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("leave", alice, "Alice")], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasLeft); assert(change.hasLeft);
assert(!change.hasJoined); assert(!change.hasJoined);
}, },
"ban": async assert => { "ban": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("ban", alice, "Alice"), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("ban", alice, "Alice")], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasLeft); assert(change.hasLeft);
assert(!change.hasJoined); assert(!change.hasJoined);
}, },
"reject invite": async assert => { "reject invite": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("invite", alice, "Alice")]); const txn = createStorage([member("invite", alice, "Alice")]);
const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn); const memberSync = writer.prepareMemberSync([], [createMemberEvent("leave", alice, "Alice")], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(!change.hasLeft); assert(!change.hasLeft);
assert(!change.hasJoined); assert(!change.hasJoined);
}, },
"lazy loaded member we already know about doens't return change": async assert => { "lazy loaded member we already know about doens't return change": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alice"), false, txn); const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice")], [], false);
assert(!change); const changes = await memberSync.write(txn);
assert.equal(changes.size, 0);
}, },
"lazy loaded member we already know about changes display name": async assert => { "lazy loaded member we already know about changes display name": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage([member("join", alice, "Alice")]); const txn = createStorage([member("join", alice, "Alice")]);
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alies"), false, txn); const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alies")], [], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(!change.hasJoined);
assert.equal(change.member.displayName, "Alies"); assert.equal(change.member.displayName, "Alies");
}, },
"unknown lazy loaded member returns change, but not considered a membership change": async assert => { "unknown lazy loaded member returns change, but not considered a join": async assert => {
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage(); const txn = createStorage();
const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alice"), false, txn); const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice")], [], false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(!change.hasJoined); assert(!change.hasJoined);
assert(!change.hasLeft); assert(!change.hasLeft);
assert.equal(change.member.membership, "join"); assert.equal(change.member.membership, "join");
assert.equal(txn.members.get(alice).displayName, "Alice"); assert.equal(txn.members.get(alice).displayName, "Alice");
}, },
"newly joined member causes a change with lookup done first": async assert => { "new join through both timeline and state": async assert => {
const event = createMemberEvent("join", alice, "Alice");
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage(); const txn = createStorage();
const member = await writer.lookupMember(event.sender, event, [event], txn); const aliceJoin = createMemberEvent("join", alice, "Alice");
assert(member); const memberSync = writer.prepareMemberSync([aliceJoin], [aliceJoin], false);
const change = await writer.writeTimelineMemberEvent(event, txn); const changes = await memberSync.write(txn);
assert(change); assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasJoined);
assert(!change.hasLeft);
}, },
"lookupMember returns closest member in the past": async assert => { "change display name in timeline with lazy loaded member in state": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage();
const memberSync = writer.prepareMemberSync(
[createMemberEvent("join", alice, "Alice")],
[createMemberEvent("join", alice, "Alies")],
false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasJoined);
assert(!change.hasLeft);
assert.equal(change.member.displayName, "Alies");
},
"lookupMemberAtEvent returns closest member in the past": async assert => {
const event1 = createMemberEvent("join", alice, "Alice"); const event1 = createMemberEvent("join", alice, "Alice");
const event2 = createMemberEvent("join", alice, "Alies"); const event2 = createMemberEvent("join", alice, "Alies");
const event3 = createMemberEvent("join", alice, "Alys"); const event3 = createMemberEvent("join", alice, "Alys");
const events = [event1, event2, event3];
// we write first because the MemberWriter assumes it is called before
// the SyncWriter does any lookups
const writer = new MemberWriter(roomId); const writer = new MemberWriter(roomId);
const txn = createStorage(); const txn = createStorage();
const member = await writer.lookupMember(event3.sender, event3, [event1, event2, event3], txn); const memberSync = await writer.prepareMemberSync([], events, false);
let member = await memberSync.lookupMemberAtEvent(event1.sender, event1, txn);
assert.equal(member, undefined);
member = await memberSync.lookupMemberAtEvent(event2.sender, event2, txn);
assert.equal(member.displayName, "Alice");
member = await memberSync.lookupMemberAtEvent(event3.sender, event3, txn);
assert.equal(member.displayName, "Alies"); assert.equal(member.displayName, "Alies");
assert.equal(txn.members.size, 0);
const changes = await memberSync.write(txn);
assert.equal(txn.members.size, 1);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasJoined);
},
"lookupMemberAtEvent falls back on state event": async assert => {
const event1 = createMemberEvent("join", alice, "Alice");
const event2 = createMemberEvent("join", alice, "Alies");
// we write first because the MemberWriter assumes it is called before
// the SyncWriter does any lookups
const writer = new MemberWriter(roomId);
const txn = createStorage();
const memberSync = await writer.prepareMemberSync([event1], [event2], false);
const member = await memberSync.lookupMemberAtEvent(event2.sender, event2, txn);
assert.equal(member.displayName, "Alice");
assert.equal(txn.members.size, 0);
const changes = await memberSync.write(txn);
assert.equal(txn.members.size, 1);
assert.equal(changes.size, 1);
const change = changes.get(alice);
assert(change.hasJoined);
},
"write works without event arrays": async assert => {
const writer = new MemberWriter(roomId);
const txn = createStorage();
const memberSync = await writer.prepareMemberSync(undefined, undefined, false);
const changes = await memberSync.write(txn);
assert.equal(changes.size, 0);
}, },
}; };
} }

View file

@ -133,38 +133,31 @@ export class SyncWriter {
return currentKey; return currentKey;
} }
async _writeStateEvents(roomResponse, memberChanges, isLimited, txn, log) { async _writeStateEvents(stateEvents, txn, log) {
// persist state let nonMemberStateEvents = 0;
const {state} = roomResponse; for (const event of stateEvents) {
if (Array.isArray(state?.events)) { // member events are written prior by MemberWriter
log.set("stateEvents", state.events.length); if (event.type !== MEMBER_EVENT_TYPE) {
for (const event of state.events) {
if (event.type === MEMBER_EVENT_TYPE) {
const memberChange = await this._memberWriter.writeStateMemberEvent(event, isLimited, txn);
if (memberChange) {
memberChanges.set(memberChange.userId, memberChange);
}
} else {
txn.roomState.set(this._roomId, event); txn.roomState.set(this._roomId, event);
nonMemberStateEvents += 1;
} }
} }
} log.set("stateEvents", nonMemberStateEvents);
} }
async _writeTimeline(timeline, currentKey, memberChanges, txn, log) { async _writeTimeline(timelineEvents, timeline, memberSync, currentKey, txn, log) {
const entries = []; const entries = [];
const updatedEntries = []; const updatedEntries = [];
if (Array.isArray(timeline?.events) && timeline.events.length) { if (timelineEvents?.length) {
// only create a fragment when we will really write an event // only create a fragment when we will really write an event
currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log);
const events = deduplicateEvents(timeline.events); log.set("timelineEvents", timelineEvents.length);
log.set("timelineEvents", events.length);
let timelineStateEventCount = 0; let timelineStateEventCount = 0;
for(const event of events) { for(const event of timelineEvents) {
// store event in timeline // store event in timeline
currentKey = currentKey.nextKey(); currentKey = currentKey.nextKey();
const storageEntry = createEventEntry(currentKey, this._roomId, event); const storageEntry = createEventEntry(currentKey, this._roomId, event);
let member = await this._memberWriter.lookupMember(event.sender, event, events, txn); let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn);
if (member) { if (member) {
storageEntry.displayName = member.displayName; storageEntry.displayName = member.displayName;
storageEntry.avatarUrl = member.avatarUrl; storageEntry.avatarUrl = member.avatarUrl;
@ -178,19 +171,13 @@ export class SyncWriter {
} }
// update state events after writing event, so for a member event, // update state events after writing event, so for a member event,
// we only update the member info after having written the member event // we only update the member info after having written the member event
// to the timeline, as we want that event to have the old profile info // to the timeline, as we want that event to have the old profile info.
if (typeof event.state_key === "string") { // member events are written prior by MemberWriter.
if (typeof event.state_key === "string" && event.type !== MEMBER_EVENT_TYPE) {
timelineStateEventCount += 1; timelineStateEventCount += 1;
if (event.type === MEMBER_EVENT_TYPE) {
const memberChange = await this._memberWriter.writeTimelineMemberEvent(event, txn);
if (memberChange) {
memberChanges.set(memberChange.userId, memberChange);
}
} else {
txn.roomState.set(this._roomId, event); txn.roomState.set(this._roomId, event);
} }
} }
}
log.set("timelineStateEventCount", timelineStateEventCount); log.set("timelineStateEventCount", timelineStateEventCount);
} }
return {currentKey, entries, updatedEntries}; return {currentKey, entries, updatedEntries};
@ -224,14 +211,13 @@ export class SyncWriter {
* @type {SyncWriterResult} * @type {SyncWriterResult}
* @property {Array<BaseEntry>} entries new timeline entries written * @property {Array<BaseEntry>} entries new timeline entries written
* @property {EventKey} newLiveKey the advanced key to write events at * @property {EventKey} newLiveKey the advanced key to write events at
* @property {Map<string, MemberChange>} memberChanges member changes in the processed sync ny user id
* *
* @param {Object} roomResponse [description] * @param {Object} roomResponse [description]
* @param {boolean} isRejoin whether the room was rejoined in the sync being processed * @param {boolean} isRejoin whether the room was rejoined in the sync being processed
* @param {Transaction} txn * @param {Transaction} txn
* @return {SyncWriterResult} * @return {SyncWriterResult}
*/ */
async writeSync(roomResponse, isRejoin, txn, log) { async writeSync(roomResponse, isRejoin, hasFetchedMembers, txn, log) {
let {timeline} = roomResponse; let {timeline} = roomResponse;
// we have rejoined the room after having synced it before, // we have rejoined the room after having synced it before,
// check for overlap with the last synced event // check for overlap with the last synced event
@ -239,13 +225,22 @@ export class SyncWriter {
if (isRejoin) { if (isRejoin) {
timeline = await this._handleRejoinOverlap(timeline, txn, log); timeline = await this._handleRejoinOverlap(timeline, txn, log);
} }
const memberChanges = new Map(); let timelineEvents;
// important this happens before _writeTimeline so if (Array.isArray(timeline?.events)) {
// members are available in the transaction timelineEvents = deduplicateEvents(timeline.events);
await this._writeStateEvents(roomResponse, memberChanges, timeline?.limited, txn, log); }
const {state} = roomResponse;
let stateEvents;
if (Array.isArray(state?.events)) {
stateEvents = state.events;
}
const memberSync = this._memberWriter.prepareMemberSync(stateEvents, timelineEvents, hasFetchedMembers);
if (stateEvents) {
await this._writeStateEvents(stateEvents, txn, log);
}
const {currentKey, entries, updatedEntries} = const {currentKey, entries, updatedEntries} =
await this._writeTimeline(timeline, this._lastLiveKey, memberChanges, txn, log); await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log);
log.set("memberChanges", memberChanges.size); const memberChanges = await memberSync.write(txn);
return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; return {entries, updatedEntries, newLiveKey: currentKey, memberChanges};
} }

View file

@ -20,9 +20,12 @@ import { exportSession, importSession } from "./export";
import { schema } from "./schema"; import { schema } from "./schema";
import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { detectWebkitEarlyCloseTxnBug } from "./quirks";
type LogType = any
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory): Promise<IDBDatabase> { const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log?: LogType) {
return openDatabase(sessionName(sessionId), createStores, schema.length, idbFactory); const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log);
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
} }
interface ServiceWorkerHandler { interface ServiceWorkerHandler {
@ -49,15 +52,15 @@ async function requestPersistedStorage(): Promise<boolean> {
export class StorageFactory { export class StorageFactory {
private _serviceWorkerHandler: ServiceWorkerHandler; private _serviceWorkerHandler: ServiceWorkerHandler;
private _idbFactory: IDBFactory; private _idbFactory: IDBFactory;
private _IDBKeyRange: typeof IDBKeyRange
constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, IDBKeyRange = window.IDBKeyRange) { constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, _IDBKeyRange = window.IDBKeyRange) {
this._serviceWorkerHandler = serviceWorkerHandler; this._serviceWorkerHandler = serviceWorkerHandler;
this._idbFactory = idbFactory; this._idbFactory = idbFactory;
// @ts-ignore this._IDBKeyRange = _IDBKeyRange;
this._IDBKeyRange = IDBKeyRange;
} }
async create(sessionId: string): Promise<Storage> { async create(sessionId: string, log?: LogType): Promise<Storage> {
await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId);
requestPersistedStorage().then(persisted => { requestPersistedStorage().then(persisted => {
// Firefox lies here though, and returns true even if the user denied the request // Firefox lies here though, and returns true even if the user denied the request
@ -67,8 +70,7 @@ export class StorageFactory {
}); });
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory); const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
// @ts-ignore
return new Storage(db, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug); return new Storage(db, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug);
} }
@ -89,10 +91,11 @@ export class StorageFactory {
} }
} }
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number): Promise<void> { async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log?: LogType): Promise<void> {
const startIdx = oldVersion || 0; const startIdx = oldVersion || 0;
return log.wrap({l: "storage migration", oldVersion, version}, async log => {
for(let i = startIdx; i < version; ++i) { for(let i = startIdx; i < version; ++i) {
await schema[i](db, txn); await log.wrap(`v${i + 1}`, log => schema[i](db, txn, log));
} }
});
} }

View file

@ -148,7 +148,7 @@ export class Store<T> extends QueryTarget<T> {
return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName))); return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)));
} }
put(value: T): Promise<IDBValidKey> { put(value: T): void {
// If this request fails, the error will bubble up to the transaction and abort it, // If this request fails, the error will bubble up to the transaction and abort it,
// which is the behaviour we want. Therefore, it is ok to not create a promise for this // which is the behaviour we want. Therefore, it is ok to not create a promise for this
// request and await it. // request and await it.
@ -159,12 +159,12 @@ export class Store<T> extends QueryTarget<T> {
// //
// Note that this can still throw synchronously, like it does for TransactionInactiveError, // Note that this can still throw synchronously, like it does for TransactionInactiveError,
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept // see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
return reqAsPromise(this._idbStore.put(value)); this._idbStore.put(value);
} }
add(value: T): Promise<IDBValidKey> { add(value: T): void {
// ok to not monitor result of request, see comment in `put`. // ok to not monitor result of request, see comment in `put`.
return reqAsPromise(this._idbStore.add(value)); this._idbStore.add(value);
} }
delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise<undefined> { delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise<undefined> {

View file

@ -25,23 +25,18 @@ function _sourceName(source: IDBIndex | IDBObjectStore): string {
function _sourceDatabase(source: IDBIndex | IDBObjectStore): string { function _sourceDatabase(source: IDBIndex | IDBObjectStore): string {
return "objectStore" in source ? return "objectStore" in source ?
source.objectStore.transaction.db.name : source.objectStore?.transaction?.db?.name :
source.transaction.db.name; source.transaction?.db?.name;
} }
export class IDBError extends StorageError { export class IDBError extends StorageError {
storeName: string; storeName: string;
databaseName: string; databaseName: string;
constructor(message: string, source: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) { constructor(message: string, sourceOrCursor: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) {
let storeName: string, databaseName: string; const source = "source" in sourceOrCursor ? sourceOrCursor.source : sourceOrCursor;
if (source instanceof IDBCursor) { const storeName = _sourceName(source);
storeName = _sourceName(source.source); const databaseName = _sourceDatabase(source);
databaseName = _sourceDatabase(source.source);
} else {
storeName = _sourceName(source);
databaseName = _sourceDatabase(source);
}
let fullMessage = `${message} on ${databaseName}.${storeName}`; let fullMessage = `${message} on ${databaseName}.${storeName}`;
if (cause) { if (cause) {
fullMessage += ": "; fullMessage += ": ";

View file

@ -1,9 +1,11 @@
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils"; import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js";
import {RoomMemberStore} from "./stores/RoomMemberStore"; import {RoomMemberStore} from "./stores/RoomMemberStore";
import {RoomStateEntry} from "./stores/RoomStateStore"; import {RoomStateEntry} from "./stores/RoomStateStore";
import {SessionStore} from "./stores/SessionStore"; import {SessionStore} from "./stores/SessionStore";
import {encodeScopeTypeKey} from "./stores/OperationStore"; import {encodeScopeTypeKey} from "./stores/OperationStore";
import {MAX_UNICODE} from "./stores/common";
// FUNCTIONS SHOULD ONLY BE APPENDED!! // FUNCTIONS SHOULD ONLY BE APPENDED!!
// the index in the array is the database version // the index in the array is the database version
@ -18,6 +20,7 @@ export const schema = [
createArchivedRoomSummaryStore, createArchivedRoomSummaryStore,
migrateOperationScopeIndex, migrateOperationScopeIndex,
createTimelineRelationsStore, createTimelineRelationsStore,
fixMissingRoomsInUserIdentities
]; ];
// TODO: how to deal with git merge conflicts of this array? // TODO: how to deal with git merge conflicts of this array?
@ -150,3 +153,47 @@ async function migrateOperationScopeIndex(db: IDBDatabase, txn: IDBTransaction):
function createTimelineRelationsStore(db: IDBDatabase) : void { function createTimelineRelationsStore(db: IDBDatabase) : void {
db.createObjectStore("timelineRelations", {keyPath: "key"}); db.createObjectStore("timelineRelations", {keyPath: "key"});
} }
//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470)
async function fixMissingRoomsInUserIdentities(db, txn, log) {
const roomSummaryStore = txn.objectStore("roomSummary");
const trackedRoomIds = [];
await iterateCursor(roomSummaryStore.openCursor(), roomSummary => {
if (roomSummary.isTrackingMembers) {
trackedRoomIds.push(roomSummary.roomId);
}
});
const outboundGroupSessionsStore = txn.objectStore("outboundGroupSessions");
const userIdentitiesStore = txn.objectStore("userIdentities");
const roomMemberStore = txn.objectStore("roomMembers");
for (const roomId of trackedRoomIds) {
let foundMissing = false;
const joinedUserIds = [];
const memberRange = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
await log.wrap({l: "room", id: roomId}, async log => {
await iterateCursor(roomMemberStore.openCursor(memberRange), member => {
if (member.membership === "join") {
joinedUserIds.push(member.userId);
}
});
log.set("joinedUserIds", joinedUserIds.length);
for (const userId of joinedUserIds) {
const identity = await reqAsPromise(userIdentitiesStore.get(userId));
const originalRoomCount = identity?.roomIds?.length;
const updatedIdentity = addRoomToIdentity(identity, userId, roomId);
if (updatedIdentity) {
log.log({l: `fixing up`, id: userId,
roomsBefore: originalRoomCount, roomsAfter: updatedIdentity.roomIds.length});
userIdentitiesStore.put(updatedIdentity);
foundMissing = true;
}
}
log.set("foundMissing", foundMissing);
if (foundMissing) {
// clear outbound megolm session,
// so we'll create a new one on the next message that will be properly shared
outboundGroupSessionsStore.delete(roomId);
}
});
}
}

View file

@ -32,7 +32,7 @@ export class AccountDataStore {
return await this._store.get(type); return await this._store.get(type);
} }
set(event: AccountDataEntry): Promise<IDBValidKey> { set(event: AccountDataEntry): void {
return this._store.put(event); this._store.put(event);
} }
} }

View file

@ -69,9 +69,9 @@ export class DeviceIdentityStore {
return this._store.get(encodeKey(userId, deviceId)); return this._store.get(encodeKey(userId, deviceId));
} }
set(deviceIdentity: DeviceIdentity): Promise<IDBValidKey> { set(deviceIdentity: DeviceIdentity): void {
deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId); deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
return this._store.put(deviceIdentity); this._store.put(deviceIdentity);
} }
getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | null> { getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | null> {

View file

@ -24,13 +24,14 @@ function encodeKey(roomId: string, sessionId: string, messageIndex: number | str
interface GroupSessionDecryption { interface GroupSessionDecryption {
eventId: string; eventId: string;
timestamp: number; timestamp: number;
key: string;
} }
export class GroupSessionDecryptionStore { type GroupSessionEntry = GroupSessionDecryption & { key: string }
private _store: Store<GroupSessionDecryption>;
constructor(store: Store<GroupSessionDecryption>) { export class GroupSessionDecryptionStore {
private _store: Store<GroupSessionEntry>;
constructor(store: Store<GroupSessionEntry>) {
this._store = store; this._store = store;
} }
@ -38,9 +39,9 @@ export class GroupSessionDecryptionStore {
return this._store.get(encodeKey(roomId, sessionId, messageIndex)); return this._store.get(encodeKey(roomId, sessionId, messageIndex));
} }
set(roomId: string, sessionId: string, messageIndex: number, decryption: GroupSessionDecryption): Promise<IDBValidKey> { set(roomId: string, sessionId: string, messageIndex: number, decryption: GroupSessionDecryption): void {
decryption.key = encodeKey(roomId, sessionId, messageIndex); (decryption as GroupSessionEntry).key = encodeKey(roomId, sessionId, messageIndex);
return this._store.put(decryption); this._store.put(decryption as GroupSessionEntry);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): Promise<undefined> {

View file

@ -48,9 +48,9 @@ export class InboundGroupSessionStore {
return this._store.get(encodeKey(roomId, senderKey, sessionId)); return this._store.get(encodeKey(roomId, senderKey, sessionId));
} }
set(session: InboundGroupSession): Promise<IDBValidKey> { set(session: InboundGroupSession): void {
session.key = encodeKey(session.roomId, session.senderKey, session.sessionId); session.key = encodeKey(session.roomId, session.senderKey, session.sessionId);
return this._store.put(session); this._store.put(session);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): Promise<undefined> {

View file

@ -41,8 +41,8 @@ export class InviteStore {
return this._inviteStore.selectAll(); return this._inviteStore.selectAll();
} }
set(invite: InviteData): Promise<IDBValidKey> { set(invite: InviteData): void {
return this._inviteStore.put(invite); this._inviteStore.put(invite);
} }
remove(roomId: string): void { remove(roomId: string): void {

View file

@ -65,9 +65,9 @@ export class OlmSessionStore {
return this._store.get(encodeKey(senderKey, sessionId)); return this._store.get(encodeKey(senderKey, sessionId));
} }
set(session: OlmSession): Promise<IDBValidKey> { set(session: OlmSession): void {
session.key = encodeKey(session.senderKey, session.sessionId); session.key = encodeKey(session.senderKey, session.sessionId);
return this._store.put(session); this._store.put(session);
} }
remove(senderKey: string, sessionId: string): Promise<undefined> { remove(senderKey: string, sessionId: string): Promise<undefined> {

View file

@ -20,15 +20,18 @@ export function encodeScopeTypeKey(scope: string, type: string): string {
return `${scope}|${type}`; return `${scope}|${type}`;
} }
interface Operation { interface BaseOperation {
id: string; id: string;
type: string;
scope: string; scope: string;
userIds: string[]; userIds: string[];
scopeTypeKey: string;
roomKeyMessage: RoomKeyMessage;
} }
type OperationType = { type: "share_room_key"; roomKeyMessage: RoomKeyMessage; }
type Operation = BaseOperation & OperationType
type OperationEntry = Operation & { scopeTypeKey: string; }
interface RoomKeyMessage { interface RoomKeyMessage {
room_id: string; room_id: string;
session_id: string; session_id: string;
@ -38,9 +41,9 @@ interface RoomKeyMessage {
} }
export class OperationStore { export class OperationStore {
private _store: Store<Operation>; private _store: Store<OperationEntry>;
constructor(store: Store<Operation>) { constructor(store: Store<OperationEntry>) {
this._store = store; this._store = store;
} }
@ -61,13 +64,13 @@ export class OperationStore {
return results; return results;
} }
add(operation: Operation): Promise<IDBValidKey> { add(operation: Operation): void {
operation.scopeTypeKey = encodeScopeTypeKey(operation.scope, operation.type); (operation as OperationEntry).scopeTypeKey = encodeScopeTypeKey(operation.scope, operation.type);
return this._store.add(operation); this._store.add(operation as OperationEntry);
} }
update(operation: Operation): Promise<IDBValidKey> { update(operation: Operation): void {
return this._store.put(operation); this._store.put(operation as OperationEntry);
} }
remove(id: string): Promise<undefined> { remove(id: string): Promise<undefined> {

View file

@ -36,7 +36,7 @@ export class OutboundGroupSessionStore {
return this._store.get(roomId); return this._store.get(roomId);
} }
set(session: OutboundSession): Promise<IDBValidKey> { set(session: OutboundSession): void {
return this._store.put(session); this._store.put(session);
} }
} }

View file

@ -73,13 +73,13 @@ export class PendingEventStore {
return !!key; return !!key;
} }
add(pendingEvent: PendingEntry): Promise<IDBValidKey> { add(pendingEvent: PendingEntry): void {
pendingEvent.key = encodeKey(pendingEvent.roomId, pendingEvent.queueIndex); pendingEvent.key = encodeKey(pendingEvent.roomId, pendingEvent.queueIndex);
return this._eventStore.add(pendingEvent); this._eventStore.add(pendingEvent);
} }
update(pendingEvent: PendingEntry): Promise<IDBValidKey> { update(pendingEvent: PendingEntry): void {
return this._eventStore.put(pendingEvent); this._eventStore.put(pendingEvent);
} }
getAll(): Promise<PendingEntry[]> { getAll(): Promise<PendingEntry[]> {

View file

@ -50,10 +50,10 @@ export class RoomMemberStore {
return this._roomMembersStore.get(encodeKey(roomId, userId)); return this._roomMembersStore.get(encodeKey(roomId, userId));
} }
async set(member: MemberData): Promise<IDBValidKey> { set(member: MemberData): void {
// Object.assign would be more typesafe, but small objects // Object.assign would be more typesafe, but small objects
(member as any).key = encodeKey(member.roomId, member.userId); (member as MemberStorageEntry).key = encodeKey(member.roomId, member.userId);
return this._roomMembersStore.put(member as MemberStorageEntry); this._roomMembersStore.put(member as MemberStorageEntry);
} }
getAll(roomId: string): Promise<MemberData[]> { getAll(roomId: string): Promise<MemberData[]> {
@ -78,10 +78,10 @@ export class RoomMemberStore {
return userIds; return userIds;
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
// exclude both keys as they are theoretical min and max, // exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max // but we should't have a match for just the room id, or room id with max
const range = this._roomMembersStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); const range = this._roomMembersStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
return this._roomMembersStore.delete(range); this._roomMembersStore.delete(range);
} }
} }

View file

@ -41,10 +41,10 @@ export class RoomStateStore {
return this._roomStateStore.get(key); return this._roomStateStore.get(key);
} }
set(roomId: string, event: StateEvent): Promise<IDBValidKey> { set(roomId: string, event: StateEvent): void {
const key = encodeKey(roomId, event.type, event.state_key); const key = encodeKey(roomId, event.type, event.state_key);
const entry = {roomId, event, key}; const entry = {roomId, event, key};
return this._roomStateStore.put(entry); this._roomStateStore.put(entry);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): Promise<undefined> {

View file

@ -42,8 +42,8 @@ export class RoomSummaryStore {
return this._summaryStore.selectAll(); return this._summaryStore.selectAll();
} }
set(summary: SummaryData): Promise<IDBValidKey> { set(summary: SummaryData): void {
return this._summaryStore.put(summary); this._summaryStore.put(summary);
} }
get(roomId: string): Promise<SummaryData | null> { get(roomId: string): Promise<SummaryData | null> {

View file

@ -27,22 +27,22 @@ export class SessionStore {
this._sessionStore = sessionStore; this._sessionStore = sessionStore;
} }
async get(key: IDBValidKey): Promise<any> { async get(key: string): Promise<any> {
const entry = await this._sessionStore.get(key); const entry = await this._sessionStore.get(key);
if (entry) { if (entry) {
return entry.value; return entry.value;
} }
} }
set(key: string, value: any): Promise<IDBValidKey> { set(key: string, value: any): void {
return this._sessionStore.put({key, value}); this._sessionStore.put({key, value});
} }
add(key: string, value: any): Promise<IDBValidKey> { add(key: string, value: any): void {
return this._sessionStore.add({key, value}); this._sessionStore.add({key, value});
} }
remove(key: IDBValidKey): Promise<undefined> { remove(key: string): void {
return this._sessionStore.delete(key); this._sessionStore.delete(key);
} }
} }

View file

@ -19,7 +19,7 @@ import { StorageError } from "../../common";
import { encodeUint32 } from "../utils"; import { encodeUint32 } from "../utils";
import {KeyLimits} from "../../common"; import {KeyLimits} from "../../common";
import {Store} from "../Store"; import {Store} from "../Store";
import {RoomEvent, StateEvent} from "../../types"; import {TimelineEvent, StateEvent} from "../../types";
interface Annotation { interface Annotation {
count: number; count: number;
@ -31,7 +31,7 @@ interface StorageEntry {
roomId: string; roomId: string;
fragmentId: number; fragmentId: number;
eventIndex: number; eventIndex: number;
event: RoomEvent | StateEvent; event: TimelineEvent | StateEvent;
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
annotations?: { [key : string]: Annotation }; annotations?: { [key : string]: Annotation };
@ -53,15 +53,15 @@ function decodeEventIdKey(eventIdKey: string): { roomId: string, eventId: string
} }
class Range { class Range {
private _IDBKeyRange: any; // TODO what's the appropriate representation here? private _IDBKeyRange: typeof IDBKeyRange;
private _only?: EventKey; private _only?: EventKey;
private _lower?: EventKey; private _lower?: EventKey;
private _upper?: EventKey; private _upper?: EventKey;
private _lowerOpen: boolean; private _lowerOpen: boolean;
private _upperOpen: boolean; private _upperOpen: boolean;
constructor(IDBKeyRange: any, only?: EventKey, lower?: EventKey, upper?: EventKey, lowerOpen: boolean = false, upperOpen: boolean = false) { constructor(_IDBKeyRange: any, only?: EventKey, lower?: EventKey, upper?: EventKey, lowerOpen: boolean = false, upperOpen: boolean = false) {
this._IDBKeyRange = IDBKeyRange; this._IDBKeyRange = _IDBKeyRange;
this._only = only; this._only = only;
this._lower = lower; this._lower = lower;
this._upper = upper; this._upper = upper;
@ -262,23 +262,23 @@ export class TimelineEventStore {
/** Inserts a new entry into the store. The combination of roomId and eventKey should not exist yet, or an error is thrown. /** Inserts a new entry into the store. The combination of roomId and eventKey should not exist yet, or an error is thrown.
* @param entry the entry to insert * @param entry the entry to insert
* @return a promise resolving to undefined if the operation was successful, or a StorageError if not. * @return nothing. To wait for the operation to finish, await the transaction it's part of.
* @throws {StorageError} ... * @throws {StorageError} ...
*/ */
insert(entry: StorageEntry): Promise<IDBValidKey> { insert(entry: StorageEntry): void {
entry.key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex); entry.key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex);
entry.eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id); entry.eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id);
// TODO: map error? or in idb/store? // TODO: map error? or in idb/store?
return this._timelineStore.add(entry); this._timelineStore.add(entry);
} }
/** Updates the entry into the store with the given [roomId, eventKey] combination. /** Updates the entry into the store with the given [roomId, eventKey] combination.
* If not yet present, will insert. Might be slower than add. * If not yet present, will insert. Might be slower than add.
* @param entry the entry to update. * @param entry the entry to update.
* @return a promise resolving to undefined if the operation was successful, or a StorageError if not. * @return nothing. To wait for the operation to finish, await the transaction it's part of.
*/ */
update(entry: StorageEntry): Promise<IDBValidKey> { update(entry: StorageEntry): void {
return this._timelineStore.put(entry); this._timelineStore.put(entry);
} }
get(roomId: string, eventKey: EventKey): Promise<StorageEntry | null> { get(roomId: string, eventKey: EventKey): Promise<StorageEntry | null> {
@ -289,10 +289,10 @@ export class TimelineEventStore {
return this._timelineStore.index("byEventId").get(encodeEventIdKey(roomId, eventId)); return this._timelineStore.index("byEventId").get(encodeEventIdKey(roomId, eventId));
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
const minKey = encodeKey(roomId, KeyLimits.minStorageKey, KeyLimits.minStorageKey); const minKey = encodeKey(roomId, KeyLimits.minStorageKey, KeyLimits.minStorageKey);
const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey, KeyLimits.maxStorageKey); const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey, KeyLimits.maxStorageKey);
const range = this._timelineStore.IDBKeyRange.bound(minKey, maxKey); const range = this._timelineStore.IDBKeyRange.bound(minKey, maxKey);
return this._timelineStore.delete(range); this._timelineStore.delete(range);
} }
} }

View file

@ -74,13 +74,13 @@ export class TimelineFragmentStore {
// should generate an id an return it? // should generate an id an return it?
// depends if we want to do anything smart with fragment ids, // depends if we want to do anything smart with fragment ids,
// like give them meaning depending on range. not for now probably ... // like give them meaning depending on range. not for now probably ...
add(fragment: Fragment): Promise<IDBValidKey> { add(fragment: Fragment): void {
(fragment as any).key = encodeKey(fragment.roomId, fragment.id); (fragment as FragmentEntry).key = encodeKey(fragment.roomId, fragment.id);
return this._store.add(fragment as FragmentEntry); this._store.add(fragment as FragmentEntry);
} }
update(fragment: FragmentEntry): Promise<IDBValidKey> { update(fragment: FragmentEntry): void {
return this._store.put(fragment); this._store.put(fragment);
} }
get(roomId: string, fragmentId: number): Promise<FragmentEntry | null> { get(roomId: string, fragmentId: number): Promise<FragmentEntry | null> {

View file

@ -39,8 +39,8 @@ export class TimelineRelationStore {
this._store = store; this._store = store;
} }
add(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise<IDBValidKey> { add(roomId: string, targetEventId: string, relType: string, sourceEventId: string): void {
return this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)}); this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
} }
remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise<undefined> { remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise<undefined> {

View file

@ -32,8 +32,8 @@ export class UserIdentityStore {
return this._store.get(userId); return this._store.get(userId);
} }
set(userIdentity: UserIdentity): Promise<IDBValidKey> { set(userIdentity: UserIdentity): void {
return this._store.put(userIdentity); this._store.put(userIdentity);
} }
remove(userId: string): Promise<undefined> { remove(userId: string): Promise<undefined> {

View file

@ -71,12 +71,19 @@ type CreateObjectStore = (db : IDBDatabase, txn: IDBTransaction | null, oldVersi
export function openDatabase(name: string, createObjectStore: CreateObjectStore, version: number, idbFactory: IDBFactory = window.indexedDB): Promise<IDBDatabase> { export function openDatabase(name: string, createObjectStore: CreateObjectStore, version: number, idbFactory: IDBFactory = window.indexedDB): Promise<IDBDatabase> {
const req = idbFactory.open(name, version); const req = idbFactory.open(name, version);
req.onupgradeneeded = (ev : IDBVersionChangeEvent) => { req.onupgradeneeded = async (ev : IDBVersionChangeEvent) => {
const req = ev.target as IDBRequest<IDBDatabase>; const req = ev.target as IDBRequest<IDBDatabase>;
const db = req.result; const db = req.result;
const txn = req.transaction; const txn = req.transaction!;
const oldVersion = ev.oldVersion; const oldVersion = ev.oldVersion;
createObjectStore(db, txn, oldVersion, version); try {
await createObjectStore(db, txn, oldVersion, version);
} catch (err) {
// try aborting on error, if that hasn't been done already
try {
txn.abort();
} catch (err) {}
}
}; };
return reqAsPromise(req); return reqAsPromise(req);
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
export type Content = { [key: string]: any } export type Content = { [key: string]: any }
export interface RoomEvent { export interface TimelineEvent {
content: Content; content: Content;
type: string; type: string;
event_id: string; event_id: string;
@ -25,4 +25,4 @@ export interface RoomEvent {
unsigned?: Content; unsigned?: Content;
} }
export type StateEvent = RoomEvent & { prev_content?: Content, state_key: string } export type StateEvent = TimelineEvent & { prev_content?: Content, state_key: string }

View file

@ -16,7 +16,8 @@ limitations under the License.
import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js"; import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js";
import {StorageFactory} from "../matrix/storage/idb/StorageFactory"; import {StorageFactory} from "../matrix/storage/idb/StorageFactory";
import {NullLogItem} from "../logging/NullLogger.js";
export function createMockStorage() { export function createMockStorage() {
return new StorageFactory(null, new FDBFactory(), FDBKeyRange).create(1); return new StorageFactory(null, new FDBFactory(), FDBKeyRange).create(1, new NullLogItem());
} }