Documentation and TODOs

This commit is contained in:
2026-05-23 18:27:13 +02:00
parent 1a6f6b3bd1
commit 9e25251f1b
2 changed files with 73 additions and 19 deletions

View File

@@ -3,6 +3,9 @@ import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
import { SSESession, type SSEvent } from "../utils/sse-session.js";
/**
* Listeners for the invitation sync client.
*/
export type InvitationSyncListeners = {
onInvitationUpdated?: (invitation: XOInvitation) => void;
onConnected?: () => void;
@@ -10,8 +13,14 @@ export type InvitationSyncListeners = {
onDisconnected?: () => void;
};
/**
* TODO: Why are there two classes in this file? I would much rather split this up by responsibility, or merge the two of them somehow.
* Leaving for now because I also don't hate the separation of concerns.
*/
/**
* Live SSE subscription for a single invitation.
* TODO: Why isnt this using the event emitter?
*/
export class InvitationSyncSubscription {
private sse: SSESession;
@@ -127,6 +136,7 @@ export class InvitationSyncClient {
/**
* Parse invitation from SSE event payloads (supports xo-cli wrapped and direct formats).
* TODO: Move into a class with a static method.
*/
function parseInvitationFromSseEvent(event: SSEvent): XOInvitation | undefined {
if (!event.data) {

View File

@@ -29,26 +29,38 @@ export class OrderInvitationTracker {
this.initialCommitCount = deps.invitation.commits?.length ?? 0;
}
/**
* Start the tracker by publishing the invitation to the sync server and subscribing to updates.
* @param deps - The dependencies for the tracker
* @returns The tracker
*/
static async start(
deps: OrderInvitationTrackerDeps,
): Promise<OrderInvitationTracker> {
// Create a new tracker.
const tracker = new OrderInvitationTracker(deps);
try {
// Publish the invitation to the sync server.
await deps.syncClient.publish(deps.invitation);
} catch (error) {
// If the invitation fails to publish, continue anyway.
deps.debug(
"Failed to publish invitation to sync server (continuing): %o",
error,
);
}
// Subscribe to updates for the invitation.
// TODO: Why isnt this using the event emitter?
tracker.subscription = deps.syncClient.subscribe(
deps.invitation.invitationIdentifier,
{
// Handle updates for the invitation.
onInvitationUpdated: (invitation) => {
void tracker.handleUpdate(invitation);
},
// Handle errors from the sync server.
onError: (error) => {
deps.debug(
"Sync subscription error for order %s: %o",
@@ -59,10 +71,16 @@ export class OrderInvitationTracker {
},
);
// Connect to the sync server.
await tracker.subscription.connect();
// Return the tracker.
return tracker;
}
/**
* Stop the tracker by closing the subscription and clearing the dispense timer.
*/
stop(): void {
this.stopped = true;
this.subscription?.close();
@@ -75,26 +93,32 @@ export class OrderInvitationTracker {
}
private async handleUpdate(invitation: XOInvitation): Promise<void> {
// If the tracker has been stopped, return.
if (this.stopped) {
return;
}
const mergedCommits = mergeCommits(
// Merge the commits from the initial invitation and the updated invitation.
const mergedCommits = OrderInvitationTracker.mergeCommits(
this.deps.invitation.commits ?? [],
invitation.commits ?? [],
);
// Update the invitation with the merged commits.
this.deps.invitation = {
...this.deps.invitation,
...invitation,
commits: mergedCommits,
};
// Get the order from the database.
const order = await this.deps.database.db
.selectFrom("orders")
.selectAll()
.where("id", "=", this.deps.orderId)
.executeTakeFirst();
// If the order is not found, or the order is completed or cancelled, stop the tracker.
if (
!order ||
order.status === "completed" ||
@@ -108,6 +132,7 @@ export class OrderInvitationTracker {
// Basing it off of the commit count is flawed and useless.
const hasCustomerActivity = mergedCommits.length > this.initialCommitCount;
// If the order is pending and there is customer activity, mark the order as paid and set the dispense timer.
if (order.status === "pending" && hasCustomerActivity) {
await this.deps.database.db
.updateTable("orders")
@@ -123,22 +148,31 @@ export class OrderInvitationTracker {
}
}
/**
* Complete the order by marking it as completed and decrementing the stock of the items.
* TODO: There is a performance improvement to be made here by passing in the order from handleUpdate. Its probably worth almost nothing though
* @returns A promise that resolves when the order has been completed
*/
private async completeOrder(): Promise<void> {
// If the tracker has been stopped, return.
if (this.stopped) {
return;
}
// Get the order from the database.
const order = await this.deps.database.db
.selectFrom("orders")
.selectAll()
.where("id", "=", this.deps.orderId)
.executeTakeFirst();
// If the order is not found, or the order is completed, stop the tracker.
if (!order || order.status === "completed") {
this.stop();
return;
}
// Mark the order as completed.
await this.deps.database.db
.updateTable("orders")
.set({ status: "completed" })
@@ -147,18 +181,24 @@ export class OrderInvitationTracker {
this.deps.debug("Order %s mock dispensed (completed)", this.deps.orderId);
// Decrement the stock of the items.
try {
// Parse the line items from the order.
const lineItems = JSON.parse(order.items) as Array<{
id: string;
quantity: number;
}>;
// Loop through the line items and decrement the stock of the items.
for (const line of lineItems) {
// Get the item from the database.
const item = await this.deps.database.db
.selectFrom("items")
.selectAll()
.where("id", "=", line.id)
.executeTakeFirst();
// If the item is found, decrement the stock of the item.
if (item) {
await this.deps.database.db
.updateTable("items")
@@ -168,27 +208,31 @@ export class OrderInvitationTracker {
}
}
} catch (error) {
this.deps.debug(
"Failed to decrement stock for order %s: %o",
this.deps.orderId,
error,
);
// If the error is not an order payment error, return a 500 error.
this.deps.debug("Failed to decrement stock for order %s: %o", this.deps.orderId, error);
}
// Stop the tracker.
this.stop();
}
}
function mergeCommits(
initial: XOInvitationCommit[],
additional: XOInvitationCommit[],
): XOInvitationCommit[] {
const map = new Map<string, XOInvitationCommit>();
for (const commit of initial) {
map.set(commit.commitIdentifier, commit);
}
/**
* Merge two arrays of commits into a single array of commits.
* TODO: Make sure the commits are actually immutable. If they aren't this would allow the "additional" commits to modify the initial commits.
* @param initial - The initial commits
* @param additional - The additional commits
* @returns The merged commits
*/
static mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
// Create a map of the initial commits.
const map = Object.fromEntries(initial.map(commit => [commit.commitIdentifier, commit]));
// Add the additional commits to the map.
for (const commit of additional) {
map.set(commit.commitIdentifier, commit);
map[commit.commitIdentifier] = commit;
}
return Array.from(map.values());
}
// Return the merged commits.
return Object.values(map);
}
};