Fix invitation syncing in realtime

This commit is contained in:
2026-06-01 11:28:18 +02:00
parent 5bec49858f
commit 5e9c6db412
5 changed files with 242 additions and 142 deletions
+16
View File
@@ -183,6 +183,7 @@ export class AppService extends EventEmitter<AppEventMap> {
// Add the invitation to the invitations array
this.invitations.push(invitation);
this.bumpInvitationRevision(invitation.data.invitationIdentifier);
// Emit the invitation-added event
this.emit("invitation-added", invitation);
@@ -201,6 +202,7 @@ export class AppService extends EventEmitter<AppEventMap> {
if (invitationIndex >= 0) {
this.invitations.splice(invitationIndex, 1);
}
this.bumpInvitationRevision(invitationIdentifier);
// Emit the invitation-removed event
this.emit("invitation-removed", invitation);
@@ -215,12 +217,14 @@ export class AppService extends EventEmitter<AppEventMap> {
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
const onUpdated = () => {
this.bumpInvitationRevision(invitationIdentifier);
this.emit("wallet-state-changed", {
reason: "invitation-updated",
invitationIdentifier,
});
};
const onStatusChanged = () => {
this.bumpInvitationRevision(invitationIdentifier);
this.emit("wallet-state-changed", {
reason: "invitation-status-changed",
invitationIdentifier,
@@ -236,6 +240,18 @@ export class AppService extends EventEmitter<AppEventMap> {
});
}
getInvitationRevision(invitationIdentifier: string): number {
return this.invitationRevisions.get(invitationIdentifier) ?? 0;
}
private bumpInvitationRevision(invitationIdentifier: string): void {
this.invitationsRevision += 1;
this.invitationRevisions.set(
invitationIdentifier,
this.getInvitationRevision(invitationIdentifier) + 1,
);
}
private detachInvitationListeners(invitationIdentifier: string): void {
const trackedInvitation = this.invitations.find(
(candidate) =>
+145 -49
View File
@@ -3,7 +3,7 @@ import type {
Engine,
GetSpendableResourcesParameters,
} from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation, deserializeInvitation } from "@xo-cash/engine";
import type {
XOInvitation,
XOInvitationCommit,
@@ -43,6 +43,13 @@ export type InvitationDependencies = {
electrum: BlockchainService;
};
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
invitation as XOInvitation & { entityIdentifier?: string };
return sharedInvitation;
}
export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Create an invitation and start the SSE Session required for it.
@@ -90,9 +97,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking
invitationInstance.start();
return invitationInstance;
}
@@ -123,6 +127,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/
private storage: BaseStorage;
private electrum: BlockchainService;
private sseUpdateQueue: Promise<void> = Promise.resolve();
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
@@ -141,8 +146,23 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.storage = dependencies.storage;
this.electrum = dependencies.electrum;
// Create a listerner for the messages from the SSE Session (sync server)
this.syncServer.on("message", this.handleSSEMessage.bind(this));
// Apply SSE updates serially so each engine update sees the latest history.
this.syncServer.on("message", (event) => {
this.enqueueSyncUpdate(() => this.handleSSEMessage(event)).catch(
(error) => {
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
},
);
});
}
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
const queuedUpdate = this.sseUpdateQueue.then(update);
this.sseUpdateQueue = queuedUpdate.catch(() => {});
return queuedUpdate;
}
/**
@@ -160,20 +180,32 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits;
await this.enqueueSyncUpdate(async () => {
// SSE messages can arrive before the GET request completes.
const combinedCommits = this.mergeCommits(
this.data.commits,
invitation?.commits ?? [],
);
// Merge the commits
const combinedCommits = this.mergeCommits(
sseCommits,
invitation?.commits ?? [],
);
try {
// Prefer keeping the engine's local invitation state in sync.
this.data = stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: combinedCommits,
}),
);
} catch (error) {
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
this.data = { ...this.data, commits: combinedCommits };
}
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
await this.storage.set(this.data.invitationIdentifier, this.data);
});
// Publish the invitation to the sync server
this.publishInvitation(this.data);
@@ -181,8 +213,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Compute and emit initial status
await this.updateStatus();
} catch (err) {
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
}
@@ -192,30 +222,83 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
*/
private handleSSEMessage(event: SSEvent): void {
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
if (data.topic === "invitation-updated") {
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
return;
}
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
const newCommits = this.mergeCommits(
this.data.commits,
invitation.commits,
);
// Set the new commits
this.data = { ...this.data, commits: newCommits };
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
this.updateStatus().catch(() => {});
// Emit the updated event
this.emit("invitation-updated", this.data);
private async handleSSEMessage(event: SSEvent): Promise<void> {
const invitation = this.parseInvitationFromSSEMessage(event);
if (
!invitation ||
invitation.invitationIdentifier !== this.data.invitationIdentifier
) {
return;
}
// Filter out commits that already exist
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
try {
this.data = stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: newCommits,
}),
);
} catch (error) {
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
this.data = { ...this.data, commits: newCommits };
}
await this.storage.set(this.data.invitationIdentifier, this.data);
await this.updateStatus();
this.emit("invitation-updated", this.data);
}
private parseInvitationFromSSEMessage(event: SSEvent): XOInvitation | null {
try {
const parsed = JSON.parse(event.data) as unknown;
const payload =
event.event === "invitation-updated"
? this.unwrapInvitationUpdatedPayload(parsed)
: this.unwrapLegacyInvitationUpdatedPayload(parsed);
if (!payload) return null;
const decoded = decodeExtendedJsonObject(payload) as XOInvitation;
return stripLocalInvitationMetadata(
deserializeInvitation(serializeInvitation(decoded)),
);
} catch {
return null;
}
}
private unwrapInvitationUpdatedPayload(payload: unknown): unknown | null {
if (
payload &&
typeof payload === "object" &&
"topic" in payload &&
"data" in payload
) {
return this.unwrapLegacyInvitationUpdatedPayload(payload);
}
return payload;
}
private unwrapLegacyInvitationUpdatedPayload(payload: unknown): unknown | null {
if (
payload &&
typeof payload === "object" &&
"topic" in payload &&
"data" in payload &&
payload.topic === "invitation-updated"
) {
return payload.data;
}
return null;
}
/**
@@ -388,12 +471,29 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
// Sync the invitation to the sync server
this.publishInvitation(this.data);
await this.publishInvitation(this.data);
// Store the accepted invitation and notify reactive consumers.
await this.storage.set(this.data.invitationIdentifier, this.data);
this.emit("invitation-updated", this.data);
// Update the status of the invitation
await this.updateStatus();
}
/**
* Accept the invitation once for this engine entity so future appends have a root commit.
*/
async ensureAccepted(): Promise<void> {
const ownCommits = await this.engine.findOwnCommits(
this.data.invitationIdentifier,
);
if (ownCommits.length === 0) {
await this.accept();
}
}
/**
* Sign the invitation
*/
@@ -435,11 +535,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Append a commit to the invitation
*/
async append(data: InvitationParameters): Promise<void> {
try {
await this.engine.acceptInvitation(this.data);
} catch (err) {
// Literally do nothing here. We are just trying to accept the invitation in case we haven't already
}
await this.ensureAccepted();
// Append the commit to the invitation
this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data);