Activities/Wallet Refactor
This commit is contained in:
@@ -1,292 +1,49 @@
|
||||
// An activity represents an event that should be saved to persistent storage(s).
|
||||
// TODO: Read more into LibAuth's concept of these.
|
||||
// Jason mentions pruning, but I don't quite understand how that will be done yet.
|
||||
// He also has the concept of "extensions" - but I'm not sure this has been fleshed out yet.
|
||||
import { type Activity } from '../storage';
|
||||
|
||||
import { UUIDV7 } from '../utils';
|
||||
import { BaseStore } from '../storage';
|
||||
//-----------------------------------------------------------------------------
|
||||
// Standardized Activities
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
// TODO: Think about different activities
|
||||
// TemplateActivity
|
||||
// CashConnectActivity
|
||||
// CoinLockActivity
|
||||
|
||||
// TODO: StorageMany adapter should be configurable to make it so set() cannot fail.
|
||||
// However, for optimization, get() should probably only retrieve from a primary (usually local) adapter.
|
||||
// Otherwise, shit will be too slow if we're doing a get() or all() from a remote source.
|
||||
// This may also solve the AES256 problem: Local does not need to be encrypted per se.
|
||||
// Sync from Remote (encrypted) to Local (non-encrypted) can happen in the background.
|
||||
|
||||
// TODO: I still haven't quite wrapped my head around LibAuth approach.
|
||||
// But I imagine there'll be some kind of "Mutable" vs "Immutable" concept for Activities.
|
||||
// Because we need to compile a "snapshot" based on Activities, if we add a Mutable Activity, we can prune previous Mutable Activities of the same identifier.
|
||||
// This will help massively with optimization:
|
||||
// 1. Our set of Activities becomes smaller (computation/storage = faster/better)
|
||||
// 2. Syncing will take less time
|
||||
// For the above, this probably means that we want to keep our Activities limited to one mutation.
|
||||
// For example, we ideally wouldn't want an Activity that modifies/refers to multiple values.
|
||||
// { type: 'freezeCoins', coins: [...] }
|
||||
// ... because we cannot sync them efficiently (we need the entire history).
|
||||
// Whereas, if we do:
|
||||
// { type: 'freezeCoin' }
|
||||
// ... and have an identifer, we can ensure that each wallet only syncs the latest frozen coin.
|
||||
// Amendment 2024-11-20:
|
||||
// For mutable, we could use property "unique: boolean".
|
||||
// For priority (e.g. txTime activity), we could use property "policy: "earlist" | latest"
|
||||
|
||||
// Type definitions
|
||||
export type Activity<Type extends string, ValueType> = {
|
||||
timestamp?: bigint;
|
||||
type: Type;
|
||||
id: string;
|
||||
value: ValueType;
|
||||
};
|
||||
|
||||
// Standardized Activities.
|
||||
export type WalletName = Activity<'WalletName', string>;
|
||||
export type WalletDescription = Activity<'WalletDescription', string>;
|
||||
export type TransactionFirstSeen = Activity<'TransactionFirstSeen', string>;
|
||||
export type TransactionMemo = Activity<'TransactionMemo', string>;
|
||||
|
||||
// XO-specific Activities.
|
||||
// TODO: These probably don't belong here and should be moved to the Wallet Codebase.
|
||||
export type XOWalletColor = Activity<'XOWalletColor', string>;
|
||||
export type XOTransactionCategory = Activity<'XOTransactionCategory', string>;
|
||||
export type XOFrozenCoin = Activity<'XOFrozenCoin', boolean>;
|
||||
|
||||
export type SnapshotByType = {
|
||||
[type: string]: {
|
||||
[id: string]: Activity<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
// Policy type definitions
|
||||
export type PolicyFunction<T = any> = (
|
||||
existingActivity: Activity<string, T> | undefined,
|
||||
newActivity: Activity<string, T>,
|
||||
) => Activity<string, T>;
|
||||
|
||||
/**
|
||||
* Implements an event sourcing pattern for managing application state.
|
||||
*
|
||||
* Event sourcing is an architectural pattern where:
|
||||
* - All changes to application state are stored as a sequence of immutable events
|
||||
* - The current state is derived by replaying these events in chronological order
|
||||
* - Events serve as both the source of truth and a complete audit log
|
||||
*
|
||||
* This implementation provides:
|
||||
* - Eventually consistent state across distributed systems
|
||||
* - Conflict resolution through configurable policies
|
||||
* - Point-in-time state reconstruction capability
|
||||
* - Separation between event storage and derived state (snapshot)
|
||||
*
|
||||
* Events (Activities) in this system:
|
||||
* - Have a unique identifier
|
||||
* - Include a timestamp for ordering
|
||||
* - Contain a type and associated value
|
||||
* - Are processed according to registered type-specific policies
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create Activities manager with a store implementation
|
||||
* const activities = new Activities(new MyBaseStore());
|
||||
*
|
||||
* // Register a custom policy for wallet name conflicts
|
||||
* activities.registerPolicy('WalletName', (existing, newActivity) => {
|
||||
* // Keep the most recent wallet name
|
||||
* return existing?.timestamp! > newActivity.timestamp! ? existing : newActivity;
|
||||
* });
|
||||
*
|
||||
* // Initialize by compiling snapshot from stored events
|
||||
* await activities.compileSnapshot();
|
||||
*
|
||||
* // Add a new activity
|
||||
* await activities.addActivity({
|
||||
* type: 'WalletName',
|
||||
* id: 'wallet123',
|
||||
* value: 'My Primary Wallet'
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @template SnapshotByType - Structured representation of the current system state
|
||||
* @template PolicyFunction - Function type for conflict resolution strategies
|
||||
*/
|
||||
export class Activities {
|
||||
// TODO: Consider splitting into its own mutable class: ActivitySnapshot.
|
||||
// This might be cleaner as we'll likely want to be able to view a raw timeline of activities.
|
||||
// So we can make Activities have its own methods for that without cluttering the class.
|
||||
public snapshot: SnapshotByType = {};
|
||||
private policies = new Map<string, PolicyFunction>();
|
||||
|
||||
// Add a map to track store keys for each snapshot key
|
||||
private storeKeyMap = new Map<string, string>();
|
||||
|
||||
constructor(public store: BaseStore) {
|
||||
this.store.on(
|
||||
'set',
|
||||
async (payload: { key: string; value: Activity<string, any> }) => {
|
||||
// When a new activity is added, process it individually
|
||||
await this.processActivity(payload.key, payload.value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all activities of a specific type
|
||||
*/
|
||||
public getActivitiesByType<T>(type: string): Activity<string, T>[] {
|
||||
return Object.values(this.snapshot[type] || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific activity by type and id
|
||||
*/
|
||||
public getActivity<T>(
|
||||
type: string,
|
||||
id: string,
|
||||
): Activity<string, T> | undefined {
|
||||
return this.snapshot[type]?.[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single activity and update the snapshot
|
||||
*/
|
||||
private async processActivity(
|
||||
storeKey: string,
|
||||
activity: Activity<string, any>,
|
||||
) {
|
||||
if (!activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activity.timestamp) {
|
||||
console.warn('Activity without timestamp encountered:', storeKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotKey = this.getSnapshotKey(activity);
|
||||
const existingActivity = this.getActivityFromSnapshot(
|
||||
activity.type,
|
||||
activity.id,
|
||||
);
|
||||
|
||||
// Apply policy to determine which activity to keep
|
||||
const keptActivity = this.applyPolicy(existingActivity, activity);
|
||||
|
||||
// Update snapshot with the kept activity
|
||||
this.setActivityInSnapshot(keptActivity);
|
||||
|
||||
if (keptActivity === activity) {
|
||||
// If we kept the new activity, update the store key mapping
|
||||
const oldStoreKey = this.storeKeyMap.get(snapshotKey);
|
||||
if (oldStoreKey) {
|
||||
// Delete the old activity from the store
|
||||
await this.store.set(oldStoreKey, null);
|
||||
}
|
||||
this.storeKeyMap.set(snapshotKey, storeKey);
|
||||
} else {
|
||||
// If we kept the existing activity, delete the new activity from the store
|
||||
await this.store.set(storeKey, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new activity to the store
|
||||
*/
|
||||
public async addActivity(activity: Activity<string, any>) {
|
||||
const timestamp = activity.timestamp ?? BigInt(Date.now());
|
||||
const activityWithTimestamp = {
|
||||
...activity,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const key = UUIDV7.fromTimestamp(timestamp).toString();
|
||||
await this.store.set(key, activityWithTimestamp);
|
||||
// Note: We don't need to call processActivity here as the 'set' event will trigger it
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a full snapshot from scratch (only needed for initialization or recovery)
|
||||
*/
|
||||
public async compileSnapshot() {
|
||||
// Clear existing snapshot
|
||||
this.snapshot = {};
|
||||
|
||||
// Get all activity data from our store
|
||||
const activities = await this.store.all<Activity<string, any>>();
|
||||
const entries = Object.entries(activities);
|
||||
|
||||
// Filter for null payloads
|
||||
const filteredEntries = entries.filter(([_, payload]) => payload);
|
||||
|
||||
// Sort activities by timestamp using UUID v7's embedded timestamp
|
||||
const activitiesSorted = filteredEntries.sort(([keyA], [keyB]) => {
|
||||
const timestampA = UUIDV7.from(keyA).getTimestamp();
|
||||
const timestampB = UUIDV7.from(keyB).getTimestamp();
|
||||
return Number(timestampA - timestampB);
|
||||
});
|
||||
|
||||
// Process each activity in order
|
||||
for (const [storeKey, activity] of activitiesSorted) {
|
||||
await this.processActivity(storeKey, activity);
|
||||
}
|
||||
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a policy for handling conflicts between activities of the same type
|
||||
* @param type Activity type to register policy for
|
||||
* @param policyFn Function that decides which activity to keep
|
||||
*/
|
||||
public registerPolicy(type: string, policyFn: PolicyFunction) {
|
||||
this.policies.set(type, policyFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an activity from the snapshot by type and id
|
||||
*/
|
||||
private getActivityFromSnapshot(
|
||||
type: string,
|
||||
id: string,
|
||||
): Activity<string, any> | undefined {
|
||||
return this.snapshot[type]?.[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an activity in the snapshot, creating necessary object structure
|
||||
*/
|
||||
private setActivityInSnapshot(activity: Activity<string, any>) {
|
||||
if (!this.snapshot[activity.type]) {
|
||||
this.snapshot[activity.type] = {};
|
||||
}
|
||||
this.snapshot[activity.type][activity.id] = activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the snapshot key for an activity
|
||||
*/
|
||||
private getSnapshotKey(activity: Activity<string, any>): string {
|
||||
return `${activity.type}:${activity.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply policy to determine which activity should be kept
|
||||
*/
|
||||
private applyPolicy(
|
||||
existingActivity: Activity<string, any> | undefined,
|
||||
newActivity: Activity<string, any>,
|
||||
): Activity<string, any> {
|
||||
const policy = this.policies.get(newActivity.type);
|
||||
|
||||
if (!policy) {
|
||||
// Default policy: compare timestamps
|
||||
if (!existingActivity) return newActivity;
|
||||
return existingActivity.timestamp! >= newActivity.timestamp!
|
||||
? existingActivity
|
||||
: newActivity;
|
||||
}
|
||||
|
||||
return policy(existingActivity, newActivity);
|
||||
}
|
||||
export interface WalletName extends Activity {
|
||||
name: 'WalletName';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface WalletDescription extends Activity {
|
||||
name: 'WalletDescription';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface WalletHDAddressGap extends Activity {
|
||||
name: 'WalletHDAddressGap';
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TransactionFirstSeen extends Activity {
|
||||
name: 'TransactionFirstSeen';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TransactionMemo extends Activity {
|
||||
name: 'TransactionMemo';
|
||||
value: string;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// XO-specific Activities
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export interface XOWalletColor extends Activity {
|
||||
name: 'XOWalletColor';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface XOTransactionCategory extends Activity {
|
||||
name: 'XOTransactionCategory';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface XOFrozenCoin extends Activity {
|
||||
name: 'XOFrozenCoin';
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user