Clean up and fixes
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { Engine, type XOEngineOptions } from '@xo-cash/engine';
|
||||
import {
|
||||
Engine,
|
||||
type XOEngineOptions,
|
||||
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
||||
generateTemplateIdentifier,
|
||||
} from '@xo-cash/engine';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
|
||||
import { Invitation } from './invitation.js';
|
||||
@@ -7,6 +12,10 @@ import { SyncServer } from '../utils/sync-server.js';
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
|
||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||
import { createHash } from 'crypto';
|
||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
||||
|
||||
export type AppEventMap = {
|
||||
'invitation-added': Invitation;
|
||||
'invitation-removed': Invitation;
|
||||
@@ -26,8 +35,32 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public invitations: Invitation[] = [];
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||
const seedHash = createHash('sha256').update(seed).digest('hex');
|
||||
|
||||
// We want to only prefix the file name
|
||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||
|
||||
console.log('Prefixed storage path:', prefixedStoragePath);
|
||||
console.log('Engine config:', config.engineConfig);
|
||||
|
||||
// Create the engine
|
||||
const engine = await Engine.create(seed, config.engineConfig);
|
||||
const engine = await Engine.create(seed, {
|
||||
...config.engineConfig,
|
||||
databaseFilename: prefixedStoragePath,
|
||||
});
|
||||
|
||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||
// Import the default P2PKH template
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
// Set default locking parameters for P2PKH
|
||||
await engine.setDefaultLockingParameters(
|
||||
generateTemplateIdentifier(p2pkhTemplate),
|
||||
'receiveOutput',
|
||||
'receiver',
|
||||
);
|
||||
|
||||
// Create our own storage for the invitations
|
||||
const storage = await Storage.create(config.invitationStoragePath);
|
||||
@@ -67,9 +100,6 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
async addInvitation(invitation: Invitation): Promise<void> {
|
||||
// Add the invitation to the invitations array
|
||||
this.invitations.push(invitation);
|
||||
|
||||
// Make sure the invitation is started
|
||||
await invitation.start();
|
||||
|
||||
// Emit the invitation-added event
|
||||
this.emit('invitation-added', invitation);
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
/**
|
||||
* Invitation Flow Manager - Manages the collaborative invitation lifecycle.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Coordinates between local Engine and remote sync-server
|
||||
* - Subscribes to SSE for real-time updates
|
||||
* - Tracks invitation state machine
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { SSESession } from '../utils/sse-client.js';
|
||||
import type { SyncClient } from './sync-client.js';
|
||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
|
||||
/**
|
||||
* States an invitation can be in.
|
||||
*/
|
||||
export type InvitationState =
|
||||
| 'created' // Just created locally
|
||||
| 'published' // Published to sync server
|
||||
| 'pending' // Waiting for other party
|
||||
| 'ready' // All requirements met, ready to sign
|
||||
| 'signed' // Signed and ready to broadcast
|
||||
| 'broadcast' // Transaction broadcast
|
||||
| 'completed' // Transaction confirmed
|
||||
| 'expired' // Invitation expired
|
||||
| 'error'; // Error state
|
||||
|
||||
/**
|
||||
* Tracked invitation with state information.
|
||||
*/
|
||||
export interface TrackedInvitation {
|
||||
/** The invitation data */
|
||||
invitation: XOInvitation;
|
||||
/** Current state */
|
||||
state: InvitationState;
|
||||
/** SSE session for updates (if subscribed) */
|
||||
sseSession?: SSESession;
|
||||
/** Timestamp when tracking started */
|
||||
trackedAt: number;
|
||||
/** Last update timestamp */
|
||||
lastUpdatedAt: number;
|
||||
/** Error message if in error state */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the invitation flow manager.
|
||||
*/
|
||||
export interface InvitationFlowEvents {
|
||||
'invitation-created': (invitation: XOInvitation) => void;
|
||||
'invitation-updated': (invitationId: string, invitation: XOInvitation) => void;
|
||||
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
||||
'error': (invitationId: string, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the invitation workflow.
|
||||
*/
|
||||
export class InvitationFlowManager extends EventEmitter {
|
||||
/** Map of tracked invitations by ID */
|
||||
private trackedInvitations: Map<string, TrackedInvitation> = new Map();
|
||||
|
||||
/** Wallet controller reference */
|
||||
private walletController: WalletController;
|
||||
|
||||
/** Sync client reference */
|
||||
private syncClient: SyncClient;
|
||||
|
||||
/**
|
||||
* Creates a new invitation flow manager.
|
||||
* @param walletController - Wallet controller instance
|
||||
* @param syncClient - Sync client instance
|
||||
*/
|
||||
constructor(walletController: WalletController, syncClient: SyncClient) {
|
||||
super();
|
||||
this.walletController = walletController;
|
||||
this.syncClient = syncClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invitation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Starts tracking an invitation.
|
||||
* @param invitation - Invitation to track
|
||||
* @param initialState - Initial state (default: 'created')
|
||||
*/
|
||||
track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation {
|
||||
const tracked: TrackedInvitation = {
|
||||
invitation,
|
||||
state: initialState,
|
||||
trackedAt: Date.now(),
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.trackedInvitations.set(invitation.invitationIdentifier, tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a tracked invitation by ID.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Tracked invitation or undefined
|
||||
*/
|
||||
get(invitationId: string): TrackedInvitation | undefined {
|
||||
return this.trackedInvitations.get(invitationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tracked invitations.
|
||||
* @returns Array of tracked invitations
|
||||
*/
|
||||
getAll(): TrackedInvitation[] {
|
||||
return Array.from(this.trackedInvitations.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of a tracked invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param state - New state
|
||||
*/
|
||||
private updateState(invitationId: string, state: InvitationState): void {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (tracked) {
|
||||
tracked.state = state;
|
||||
tracked.lastUpdatedAt = Date.now();
|
||||
this.emit('invitation-state-changed', invitationId, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a tracked invitation with new data.
|
||||
* @param invitation - Updated invitation
|
||||
*/
|
||||
private updateInvitation(invitation: XOInvitation): void {
|
||||
const tracked = this.trackedInvitations.get(invitation.invitationIdentifier);
|
||||
if (tracked) {
|
||||
tracked.invitation = invitation;
|
||||
tracked.lastUpdatedAt = Date.now();
|
||||
this.emit('invitation-updated', invitation.invitationIdentifier, invitation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops tracking an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
*/
|
||||
untrack(invitationId: string): void {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (tracked?.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
this.trackedInvitations.delete(invitationId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flow Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a new invitation and starts tracking it.
|
||||
* @param templateIdentifier - Template ID
|
||||
* @param actionIdentifier - Action ID
|
||||
* @returns Created and tracked invitation
|
||||
*/
|
||||
async createInvitation(
|
||||
templateIdentifier: string,
|
||||
actionIdentifier: string,
|
||||
): Promise<TrackedInvitation> {
|
||||
// Create invitation via wallet controller
|
||||
const invitation = await this.walletController.createInvitation(
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
);
|
||||
|
||||
// Track the invitation
|
||||
const tracked = this.track(invitation, 'created');
|
||||
this.emit('invitation-created', invitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an invitation to the sync server.
|
||||
* @param invitationId - Invitation ID to publish
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async publishInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Post to sync server
|
||||
await this.syncClient.postInvitation(tracked.invitation);
|
||||
|
||||
// Update state
|
||||
this.updateState(invitationId, 'published');
|
||||
|
||||
return tracked;
|
||||
} catch (error) {
|
||||
tracked.state = 'error';
|
||||
tracked.error = error instanceof Error ? error.message : String(error);
|
||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to SSE updates for an invitation.
|
||||
* @param invitationId - Invitation ID to subscribe to
|
||||
*/
|
||||
async subscribeToUpdates(invitationId: string): Promise<void> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Close existing SSE session if any
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
|
||||
// Create new SSE session
|
||||
const sseUrl = this.syncClient.getSSEUrl(invitationId);
|
||||
|
||||
tracked.sseSession = await SSESession.from(sseUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
onMessage: (event) => {
|
||||
this.handleSSEMessage(invitationId, event);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(`SSE error for ${invitationId}:`, error);
|
||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
onConnected: () => {
|
||||
console.log(`SSE connected for invitation: ${invitationId}`);
|
||||
},
|
||||
onDisconnected: () => {
|
||||
console.log(`SSE disconnected for invitation: ${invitationId}`);
|
||||
},
|
||||
attemptReconnect: true,
|
||||
persistent: true,
|
||||
retryDelay: 3000,
|
||||
});
|
||||
|
||||
// Update state to pending (waiting for updates)
|
||||
this.updateState(invitationId, 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an SSE message for an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param event - SSE event
|
||||
*/
|
||||
private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void {
|
||||
try {
|
||||
// Parse the event data
|
||||
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
|
||||
if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') {
|
||||
// Decode the invitation data (handles ExtJSON)
|
||||
const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed);
|
||||
const invitation = invitationData as XOInvitation;
|
||||
|
||||
// Update tracked invitation
|
||||
this.updateInvitation(invitation);
|
||||
|
||||
// Check if all requirements are met
|
||||
this.checkInvitationState(invitationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing SSE message for ${invitationId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and updates the state of an invitation based on its data.
|
||||
* @param invitationId - Invitation ID to check
|
||||
*/
|
||||
private async checkInvitationState(invitationId: string): Promise<void> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) return;
|
||||
|
||||
try {
|
||||
// Check missing requirements
|
||||
const missing = await this.walletController.getMissingRequirements(tracked.invitation);
|
||||
|
||||
// If no missing inputs/outputs, it's ready to sign
|
||||
const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0;
|
||||
const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0;
|
||||
|
||||
if (hasNoMissingInputs && hasNoMissingOutputs) {
|
||||
this.updateState(invitationId, 'ready');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during state check
|
||||
console.error(`Error checking invitation state: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports an invitation from the sync server.
|
||||
* @param invitationId - Invitation ID to import
|
||||
* @returns Tracked invitation
|
||||
*/
|
||||
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
// Fetch from sync server
|
||||
const invitation = await this.syncClient.getInvitation(invitationId);
|
||||
if (!invitation) {
|
||||
throw new Error(`Invitation not found on server: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Track the invitation
|
||||
const tracked = this.track(invitation, 'pending');
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an invitation (joins as a participant).
|
||||
* @param invitationId - Invitation ID to accept
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Accept via wallet controller
|
||||
const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation);
|
||||
this.updateInvitation(updatedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends data to an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @param params - Data to append
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async appendToInvitation(
|
||||
invitationId: string,
|
||||
params: Parameters<WalletController['appendInvitation']>[1],
|
||||
): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Append via wallet controller
|
||||
const updatedInvitation = await this.walletController.appendInvitation(
|
||||
tracked.invitation,
|
||||
params,
|
||||
);
|
||||
this.updateInvitation(updatedInvitation);
|
||||
|
||||
// Publish update to sync server
|
||||
await this.syncClient.updateInvitation(updatedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Updated tracked invitation
|
||||
*/
|
||||
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Sign via wallet controller
|
||||
const signedInvitation = await this.walletController.signInvitation(tracked.invitation);
|
||||
this.updateInvitation(signedInvitation);
|
||||
this.updateState(invitationId, 'signed');
|
||||
|
||||
// Publish signed invitation to sync server
|
||||
await this.syncClient.updateInvitation(signedInvitation);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts the transaction for an invitation.
|
||||
* @param invitationId - Invitation ID
|
||||
* @returns Transaction hash
|
||||
*/
|
||||
async broadcastTransaction(invitationId: string): Promise<string> {
|
||||
const tracked = this.trackedInvitations.get(invitationId);
|
||||
if (!tracked) {
|
||||
throw new Error(`Invitation not found: ${invitationId}`);
|
||||
}
|
||||
|
||||
// Execute action (broadcast)
|
||||
const txHash = await this.walletController.executeAction(tracked.invitation, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
this.updateState(invitationId, 'broadcast');
|
||||
|
||||
// Close SSE session since we're done
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
delete tracked.sseSession;
|
||||
}
|
||||
|
||||
return txHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
// Close all SSE sessions
|
||||
for (const tracked of this.trackedInvitations.values()) {
|
||||
if (tracked.sseSession) {
|
||||
tracked.sseSession.close();
|
||||
}
|
||||
}
|
||||
this.trackedInvitations.clear();
|
||||
}
|
||||
}
|
||||
@@ -44,15 +44,24 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
||||
}
|
||||
|
||||
// Make sure the engine has the template imported
|
||||
await dependencies.engine.importTemplate(invitation.templateIdentifier);
|
||||
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||
}
|
||||
|
||||
console.log('Invitation:', invitation);
|
||||
|
||||
// Create the invitation
|
||||
const invitationInstance = new Invitation(invitation, dependencies);
|
||||
|
||||
console.log('Invitation instance:', invitationInstance);
|
||||
|
||||
// Start the invitation and its tracking
|
||||
await invitationInstance.start();
|
||||
|
||||
console.log('Invitation started:', invitationInstance);
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
|
||||
@@ -114,7 +123,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...invitation, commits: [...sseCommits, ...invitation.commits] };
|
||||
this.data = { ...this.data, ...invitation, commits: [...sseCommits, ...(invitation?.commits ?? [])] };
|
||||
|
||||
console.log('Invitation data:', this.data);
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
@@ -132,10 +143,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
if (data.topic === 'invitation-updated') {
|
||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||
console.log('Invitation updated:', invitation);
|
||||
|
||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('New commits:', invitation.commits);
|
||||
|
||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||
const newCommits = invitation.commits.filter(commit => !this.data.commits.some(c => c.commitIdentifier === commit.commitIdentifier));
|
||||
this.data.commits.push(...newCommits);
|
||||
@@ -271,28 +286,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Get the missing requirements for the invitation
|
||||
*/
|
||||
get missingRequirements() {
|
||||
async getMissingRequirements() {
|
||||
return this.engine.listMissingRequirements(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the requirements for the invitation
|
||||
*/
|
||||
get requirements() {
|
||||
async getRequirements() {
|
||||
return this.engine.listRequirements(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available roles for the invitation
|
||||
*/
|
||||
get availableRoles() {
|
||||
async getAvailableRoles() {
|
||||
return this.engine.listAvailableRoles(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the starting actions for the invitation
|
||||
*/
|
||||
get startingActions() {
|
||||
async getStartingActions() {
|
||||
return this.engine.listStartingActions(this.data.templateIdentifier);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { decodeExtendedJsonObject, encodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
||||
|
||||
export class Storage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
@@ -35,10 +35,12 @@ export class Storage {
|
||||
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
// Encode the extended json object
|
||||
const encodedValue = encodeExtendedJsonObject(value);
|
||||
const encodedValue = encodeExtendedJson(value);
|
||||
console.log('Encoded value:', encodedValue);
|
||||
|
||||
// Insert or replace the value into the database with full key (including basePath)
|
||||
const fullKey = this.getFullKey(key);
|
||||
console.log('Full key:', fullKey);
|
||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ export class Storage {
|
||||
// Decode the extended json objects and strip basePath from keys
|
||||
return filteredRows.map(row => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJsonObject(row.value)
|
||||
value: decodeExtendedJson(row.value)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -81,7 +83,7 @@ export class Storage {
|
||||
if (!row) return null;
|
||||
|
||||
// Decode the extended json object
|
||||
return decodeExtendedJsonObject(row.value);
|
||||
return decodeExtendedJson(row.value);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Sync Server Client - HTTP client for sync-server communication.
|
||||
*
|
||||
* Handles:
|
||||
* - Creating/updating invitations on the server
|
||||
* - Fetching invitations by ID
|
||||
* - ExtJSON encoding/decoding for data transfer
|
||||
*/
|
||||
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js';
|
||||
|
||||
/**
|
||||
* Response from the sync server.
|
||||
*/
|
||||
export interface SyncServerResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client for sync-server communication.
|
||||
*/
|
||||
export class SyncClient {
|
||||
/** Base URL of the sync server */
|
||||
private baseUrl: string;
|
||||
|
||||
/**
|
||||
* Creates a new sync client.
|
||||
* @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000)
|
||||
*/
|
||||
constructor(baseUrl: string) {
|
||||
// Remove trailing slash if present
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an HTTP request to the sync server.
|
||||
* @param method - HTTP method
|
||||
* @param path - Request path
|
||||
* @param body - Optional request body
|
||||
* @returns Response data
|
||||
*/
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
// Encode body using ExtJSON for proper BigInt and Uint8Array serialization
|
||||
options.body = encodeExtendedJson(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
// Return empty object if no response body
|
||||
if (!responseText) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
// Decode response using ExtJSON
|
||||
return decodeExtendedJson(responseText) as T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invitation Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Posts an invitation to the sync server (create or update).
|
||||
* @param invitation - Invitation to post
|
||||
* @returns The stored invitation
|
||||
*/
|
||||
async postInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
return this.request<XOInvitation>('POST', '/invitations', invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an invitation from the sync server.
|
||||
* @param invitationIdentifier - Invitation ID to fetch
|
||||
* @returns The invitation or undefined if not found
|
||||
*/
|
||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||
try {
|
||||
// Use query parameter for GET request (can't have body)
|
||||
const response = await this.request<XOInvitation>(
|
||||
'GET',
|
||||
`/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return undefined if not found (404)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an invitation on the sync server.
|
||||
* @param invitation - Updated invitation
|
||||
* @returns The updated invitation
|
||||
*/
|
||||
async updateInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
// Uses the same POST endpoint which handles both create and update
|
||||
return this.postInvitation(invitation);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health Check
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks if the sync server is healthy.
|
||||
* @returns True if server is healthy
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.request<{ status: string }>('GET', '/health');
|
||||
return response.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base URL of the sync server.
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the SSE endpoint URL for an invitation.
|
||||
* @param invitationId - Invitation ID to subscribe to
|
||||
* @returns SSE endpoint URL
|
||||
*/
|
||||
getSSEUrl(invitationIdentifier: string): string {
|
||||
return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user