Activities/Wallet Refactor

This commit is contained in:
Jim Hamill
2025-04-18 05:17:20 +00:00
parent 408d3b8d8c
commit 7e4508cf5a
18 changed files with 706 additions and 1111 deletions
+275
View File
@@ -0,0 +1,275 @@
import { BaseStore } from './base-store.js';
import { StoreComposite } from './store-composite.js';
import { UUIDV7 } from '../utils';
// Make the given property in a type optional.
// TODO: This is an extremely useful utility. If we use it elsewhere, move to own "type-utils.ts" file.
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Base Activity interface
export interface Activity {
entityId: string;
name: string;
value: unknown;
timestamp: bigint;
}
// Define the snapshot structure
export type SnapshotByType = {
[type: string]: {
[id: string]: Activity;
};
};
// Policy type definition
export type PolicyFunction = (
existingActivity: Activity | undefined,
newActivity: Activity,
) => Activity;
/**
* Implements an event sourcing pattern for managing application state
* with a simplified interface focused on the current snapshot.
*
* @example
* ```typescript
* // Create Activities manager with a store implementation
* const activities = await Activities.fromLocalStore(localStore);
*
* // 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;
* });
*
* // Add a new wallet name activity
* const walletName: WalletNameInput = {
* name: 'WalletName',
* value: 'Test Wallet'
* };
* const savedActivity = await activities.add(walletName);
* ```
*/
export class Activities {
public snapshot: SnapshotByType = {};
private policies = new Map<string, PolicyFunction>();
private storeKeyMap = new Map<string, string>();
/**
* Create an Activities instance from the given composite store.
*/
static async from(compositeStore: StoreComposite) {
const activities = new Activities(compositeStore);
await activities.compileSnapshot();
return activities;
}
/**
* Create an Activities instance from the given primary and secondary stores
*/
static async fromStores(
primaryStore: BaseStore,
secondaryStores?: Array<BaseStore>,
): Promise<Activities> {
const compositeStore = await StoreComposite.from(
primaryStore,
secondaryStores,
);
return Activities.from(compositeStore);
}
private constructor(public store: StoreComposite) {
this.store.on(
'set',
async (payload: { key: string; value: Activity | null }) => {
if (payload.value !== null) {
await this.processActivity(payload.key, payload.value);
}
},
);
}
/**
* Add a new activity to the store
* @param activity The activity to add (id and timestamp are optional)
* @returns The stored activity with all fields populated
*/
public async add<T extends Optional<Activity, 'timestamp'>>(
activity: T,
): Promise<Activity> {
// Generate timestamp if not provided
const timestamp = activity.timestamp ?? BigInt(Date.now());
// Generate a UUID based on the timestamp
const key = UUIDV7.fromTimestamp(timestamp).toString();
// Generate an ID if not provided
const id = activity.entityId ?? key;
const completeActivity: Activity = {
name: activity.name,
value: activity.value,
entityId: id,
timestamp,
};
await this.store.set(key, completeActivity);
return completeActivity;
}
/**
* Get a specific activity by type and id with type inference
*/
public get<T extends Activity>(type: T['name'], id: string): T | undefined {
const activity = this.snapshot[type]?.[id];
return activity as T | undefined;
}
/**
* Get all activities of a specific type
*/
public getByType<T extends Activity>(type: T['name']): T[] {
const activities = this.snapshot[type];
if (!activities) {
return [];
}
return Object.values(activities) as T[];
}
/**
* 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): void {
this.policies.set(type, policyFn);
}
/**
* Process a single activity and update the snapshot
*/
private async processActivity(
storeKey: string,
activity: Activity,
): Promise<void> {
if (!activity) {
return;
}
// Ensure timestamp exists by extracting from UUID if needed
if (!activity.timestamp) {
activity.timestamp = UUIDV7.from(storeKey).getTimestamp();
}
// Ensure ID exists
if (!activity.entityId) {
activity.entityId = storeKey;
}
const snapshotKey = this.getSnapshotKey(activity);
const existingActivity = this.getActivityFromSnapshot(
activity.name,
activity.entityId,
);
// 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 && oldStoreKey !== storeKey) {
// Delete the old activity from the store
await this.store.set(oldStoreKey, null);
}
this.storeKeyMap.set(snapshotKey, storeKey);
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
// If we kept the existing activity, delete the new activity from the store
await this.store.set(storeKey, null);
}
}
/**
* Compile a full snapshot from scratch
* This happens automatically on initialization
*/
private async compileSnapshot(): Promise<SnapshotByType> {
// Clear existing snapshot
this.snapshot = {};
this.storeKeyMap.clear();
// Get all activity data from our store
const activities = await this.store.all<Activity>();
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;
}
/**
* Get an activity from the snapshot by type and id
*/
private getActivityFromSnapshot(
type: string,
id: string,
): Activity | undefined {
return this.snapshot[type]?.[id];
}
/**
* Set an activity in the snapshot, creating necessary object structure
*/
private setActivityInSnapshot(activity: Activity): void {
if (!this.snapshot[activity.name]) {
this.snapshot[activity.name] = {};
}
this.snapshot[activity.name][activity.entityId] = activity;
}
/**
* Get the snapshot key for an activity
*/
private getSnapshotKey(activity: Activity): string {
return `${activity.name}:${activity.entityId}`;
}
/**
* Apply policy to determine which activity should be kept
*/
private applyPolicy(
existingActivity: Activity | undefined,
newActivity: Activity,
): Activity {
const policy = this.policies.get(newActivity.name);
if (!policy) {
// Default policy: compare timestamps
if (!existingActivity) return newActivity;
return existingActivity.timestamp >= newActivity.timestamp
? existingActivity
: newActivity;
}
return policy(existingActivity, newActivity);
}
}