Merge branch 'xo_wallet_integration' into 'development'
XO wallet integration See merge request GeneralProtocols/xo/stack!77
This commit is contained in:
@@ -2,10 +2,11 @@ import { Transaction } from './transaction.js';
|
|||||||
|
|
||||||
import { binToHex } from '@bitauth/libauth';
|
import { binToHex } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
// TODO: The below should probably be moved and renamed.
|
||||||
/**
|
/**
|
||||||
* Sort transactions by the order in which they should be broadcast.
|
* Sort transactions by the order in which they should be broadcast.
|
||||||
*/
|
*/
|
||||||
export function sortTransactions(
|
export function sortTransactionsByDependencies(
|
||||||
transactions: Array<Transaction>,
|
transactions: Array<Transaction>,
|
||||||
): Array<Transaction> {
|
): Array<Transaction> {
|
||||||
const transactionMap = transactions.reduce(
|
const transactionMap = transactions.reduce(
|
||||||
@@ -139,6 +140,24 @@ export class ExtMap<T> extends Map<PropertyKey, T> {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new ExtMap containing a subset of entries from the original Map
|
||||||
|
* @param start - The starting index (inclusive)
|
||||||
|
* @param end - The ending index (exclusive)
|
||||||
|
* @returns A new ExtMap with the sliced entries
|
||||||
|
*/
|
||||||
|
slice(start: number, end?: number): ExtMap<T> {
|
||||||
|
const keys = Array.from(this.keys());
|
||||||
|
const slicedKeys = keys.slice(start, end);
|
||||||
|
|
||||||
|
const result = new ExtMap<T>();
|
||||||
|
for (const key of slicedKeys) {
|
||||||
|
result.set(key, this.get(key) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the collection to a plain object
|
* Converts the collection to a plain object
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './blockchain-electrum.js';
|
|||||||
export * from './blockchain-inmemory.js';
|
export * from './blockchain-inmemory.js';
|
||||||
export * from './traits.js';
|
export * from './traits.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
export * from './utils.js';
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// TODO: If we end up with several utilities, we might want to consider a refactor:
|
||||||
|
// 1. Convert BlockchainTransactions into a class with a sort function.
|
||||||
|
// 2. Have WalletTransactions extends from BlockchainTransactions.
|
||||||
|
|
||||||
|
import { type BlockchainTransactions } from './types.js';
|
||||||
|
|
||||||
|
export function sortTransactions(
|
||||||
|
transactions: BlockchainTransactions,
|
||||||
|
order: 'ASC' | 'DESC',
|
||||||
|
): BlockchainTransactions {
|
||||||
|
return transactions.sort((a, b) => {
|
||||||
|
// Name the outcomes, or I'll go insane.
|
||||||
|
const aFirst = order === 'DESC' ? -1 : 1;
|
||||||
|
const bFirst = order === 'DESC' ? 1 : -1;
|
||||||
|
|
||||||
|
// Sort by mempool, then blockheight, then dependency.
|
||||||
|
// height = 0 -- mempool
|
||||||
|
// height > 0 -- confirmed
|
||||||
|
// height < 0 -- mempool with dependencies
|
||||||
|
|
||||||
|
// If a is dependant on a tx in the mempool, while b is in the mempool or confirmed, sort a before b.
|
||||||
|
if (a.height <= -1 && b.height >= 0) {
|
||||||
|
return aFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If b is dependant on a tx in the mempool, while a is in the mempool or confirmed, sort b before a.
|
||||||
|
if (b.height <= -1 && a.height >= 0) {
|
||||||
|
return bFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a is in the mempool, while b is confirmed, sort a before b.
|
||||||
|
if (a.height <= 0 && b.height > 0) {
|
||||||
|
return aFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If b is in the mempool, while a is confirmed, sort b before a.
|
||||||
|
if (b.height <= 0 && a.height > 0) {
|
||||||
|
return bFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a has a higher blockheight than b, sort a before b.
|
||||||
|
if (a.height > b.height) {
|
||||||
|
return aFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If b has a higher blockheight than a, sort b before a.
|
||||||
|
if (b.height > a.height) {
|
||||||
|
return bFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There was no criteria to sort by, so return 0.
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './index.js';
|
|||||||
|
|
||||||
// These exports will only work in a browser environment.
|
// These exports will only work in a browser environment.
|
||||||
export * from './storage/store-indexeddb.js';
|
export * from './storage/store-indexeddb.js';
|
||||||
|
export * from './storage/store-localstorage.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseStore } from './base-store.js';
|
import { BaseStore } from './base-store.js';
|
||||||
import { StoreComposite } from './store-composite.js';
|
import { StoreComposite } from './store-composite.js';
|
||||||
|
|
||||||
import { UUIDV7 } from '../utils/index.js';
|
import { EventEmitter, UUIDV7 } from '../utils/index.js';
|
||||||
|
|
||||||
// Make the given property in a type optional.
|
// 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.
|
// TODO: This is an extremely useful utility. If we use it elsewhere, move to own "type-utils.ts" file.
|
||||||
@@ -9,10 +9,17 @@ type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
|||||||
|
|
||||||
// Base Activity interface
|
// Base Activity interface
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
entityId: string;
|
/** Key (Type) of this activity. */
|
||||||
name: string;
|
key: string;
|
||||||
|
|
||||||
|
/** Value for this activity. */
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
|
||||||
|
/** Timestamp for this activity. */
|
||||||
timestamp: bigint;
|
timestamp: bigint;
|
||||||
|
|
||||||
|
/** Entity ID may refer to a transaction id or other specific identifier. */
|
||||||
|
entityId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the snapshot structure
|
// Define the snapshot structure
|
||||||
@@ -28,6 +35,10 @@ export type PolicyFunction = (
|
|||||||
newActivity: Activity,
|
newActivity: Activity,
|
||||||
) => Activity;
|
) => Activity;
|
||||||
|
|
||||||
|
export type ActivitiesEvents = {
|
||||||
|
[key: `updated:${string}`]: { activity: Activity };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements an event sourcing pattern for managing application state
|
* Implements an event sourcing pattern for managing application state
|
||||||
* with a simplified interface focused on the current snapshot.
|
* with a simplified interface focused on the current snapshot.
|
||||||
@@ -51,7 +62,14 @@ export type PolicyFunction = (
|
|||||||
* const savedActivity = await activities.add(walletName);
|
* const savedActivity = await activities.add(walletName);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class Activities {
|
export class Activities extends EventEmitter<ActivitiesEvents> {
|
||||||
|
// Dependencies.
|
||||||
|
public readonly store: StoreComposite;
|
||||||
|
|
||||||
|
// State.
|
||||||
|
// TODO: If you can refactor this around your new Storage mechanism, things become a lot simpler:
|
||||||
|
// 1. You don't need to define such a clunky snapshot format because you have indexes.
|
||||||
|
// 2. Caching of snapshots likely becomes possible (because you can use a storage adapter).
|
||||||
public snapshot: SnapshotByType = {};
|
public snapshot: SnapshotByType = {};
|
||||||
private policies = new Map<string, PolicyFunction>();
|
private policies = new Map<string, PolicyFunction>();
|
||||||
private storeKeyMap = new Map<string, string>();
|
private storeKeyMap = new Map<string, string>();
|
||||||
@@ -59,27 +77,20 @@ export class Activities {
|
|||||||
/**
|
/**
|
||||||
* Create an Activities instance from the given composite store.
|
* Create an Activities instance from the given composite store.
|
||||||
*/
|
*/
|
||||||
static async from(compositeStore: StoreComposite) {
|
static async from(stores?: Array<BaseStore>) {
|
||||||
|
const compositeStore = await StoreComposite.from(stores);
|
||||||
const activities = new Activities(compositeStore);
|
const activities = new Activities(compositeStore);
|
||||||
await activities.compileSnapshot();
|
await activities.compileSnapshot();
|
||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private constructor(store: StoreComposite) {
|
||||||
* Create an Activities instance from the given primary and secondary stores
|
super();
|
||||||
*/
|
|
||||||
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) {
|
// Assign our dependencies.
|
||||||
|
this.store = store;
|
||||||
|
|
||||||
|
// Register callbacks.
|
||||||
this.store.on(
|
this.store.on(
|
||||||
'set',
|
'set',
|
||||||
async (payload: { key: string; value: Activity | null }) => {
|
async (payload: { key: string; value: Activity | null }) => {
|
||||||
@@ -90,47 +101,78 @@ export class Activities {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Additional argument to "add": Something like a quarum.
|
||||||
|
// But, instead of a quorum, it's a function that takes the resolve of all "set" promises.
|
||||||
|
// That way, we can implement custom storage policies?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new activity to the store
|
* Add a new activity to the store
|
||||||
* @param activity The activity to add (id and timestamp are optional)
|
* @param activity The activity to add (id and timestamp are optional)
|
||||||
* @returns The stored activity with all fields populated
|
* @returns The stored activity with all fields populated
|
||||||
*/
|
*/
|
||||||
public async add<T extends Optional<Activity, 'timestamp'>>(
|
public async add<T extends Activity>(
|
||||||
activity: T,
|
activity: Optional<T, 'entityId' | 'timestamp'>,
|
||||||
): Promise<Activity> {
|
): Promise<Activity> {
|
||||||
// Generate timestamp if not provided
|
// Generate timestamp if not provided
|
||||||
const timestamp = activity.timestamp ?? BigInt(Date.now());
|
const timestamp = activity.timestamp ?? BigInt(Date.now());
|
||||||
|
|
||||||
// Generate a UUID based on the timestamp
|
// Generate a UUID based on the timestamp
|
||||||
const key = UUIDV7.fromTimestamp(timestamp).toString();
|
const uuid = UUIDV7.fromTimestamp(timestamp).toString();
|
||||||
|
|
||||||
// Generate an ID if not provided
|
// If this doesn't reference a specific entity id, put it at root level (empty string: "")
|
||||||
const id = activity.entityId ?? key;
|
const id = activity.entityId ?? '';
|
||||||
|
|
||||||
const completeActivity: Activity = {
|
const completeActivity: Activity = {
|
||||||
name: activity.name,
|
key: activity.key,
|
||||||
value: activity.value,
|
value: activity.value,
|
||||||
entityId: id,
|
entityId: id,
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.store.set(key, completeActivity);
|
await this.store.set(uuid, completeActivity);
|
||||||
|
|
||||||
return completeActivity;
|
return completeActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async remove<T extends Activity>(
|
||||||
|
activity: Pick<T, 'key' | 'entityId'>,
|
||||||
|
) {
|
||||||
|
// Generate timestamp.
|
||||||
|
const timestamp = BigInt(Date.now());
|
||||||
|
|
||||||
|
// Generate a UUID based on the timestamp
|
||||||
|
const uuid = UUIDV7.fromTimestamp(timestamp).toString();
|
||||||
|
|
||||||
|
// If this doesn't reference a specific entity id, put it at root level (empty string: "")
|
||||||
|
const id = activity.entityId ?? '';
|
||||||
|
|
||||||
|
const removedActivity: Activity = {
|
||||||
|
key: activity.key,
|
||||||
|
value: null,
|
||||||
|
entityId: id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.store.set(uuid, removedActivity);
|
||||||
|
|
||||||
|
return removedActivity;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific activity by type and id with type inference
|
* Get a specific activity by type and id with type inference
|
||||||
*/
|
*/
|
||||||
public get<T extends Activity>(type: T['name'], id: string): T | undefined {
|
public get<T extends Activity>(
|
||||||
const activity = this.snapshot[type]?.[id];
|
type: T['key'],
|
||||||
|
entityId: string = '',
|
||||||
|
): T | undefined {
|
||||||
|
const activity = this.snapshot[type]?.[entityId];
|
||||||
return activity as T | undefined;
|
return activity as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all activities of a specific type
|
* Get all activities of a specific type
|
||||||
*/
|
*/
|
||||||
public getByType<T extends Activity>(type: T['name']): T[] {
|
public getByType<T extends Activity>(type: T['key']): T[] {
|
||||||
const activities = this.snapshot[type];
|
const activities = this.snapshot[type];
|
||||||
if (!activities) {
|
if (!activities) {
|
||||||
return [];
|
return [];
|
||||||
@@ -157,20 +199,14 @@ export class Activities {
|
|||||||
if (!activity) {
|
if (!activity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure timestamp exists by extracting from UUID if needed
|
// Ensure timestamp exists by extracting from UUID if needed
|
||||||
if (!activity.timestamp) {
|
if (!activity.timestamp) {
|
||||||
activity.timestamp = UUIDV7.from(storeKey).getTimestamp();
|
activity.timestamp = UUIDV7.from(storeKey).getTimestamp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure ID exists
|
|
||||||
if (!activity.entityId) {
|
|
||||||
activity.entityId = storeKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotKey = this.getSnapshotKey(activity);
|
const snapshotKey = this.getSnapshotKey(activity);
|
||||||
const existingActivity = this.getActivityFromSnapshot(
|
const existingActivity = this.getActivityFromSnapshot(
|
||||||
activity.name,
|
activity.key,
|
||||||
activity.entityId,
|
activity.entityId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,12 +221,15 @@ export class Activities {
|
|||||||
const oldStoreKey = this.storeKeyMap.get(snapshotKey);
|
const oldStoreKey = this.storeKeyMap.get(snapshotKey);
|
||||||
if (oldStoreKey && oldStoreKey !== storeKey) {
|
if (oldStoreKey && oldStoreKey !== storeKey) {
|
||||||
// Delete the old activity from the store
|
// Delete the old activity from the store
|
||||||
await this.store.set(oldStoreKey, null);
|
// await this.store.set(oldStoreKey, null);
|
||||||
}
|
}
|
||||||
this.storeKeyMap.set(snapshotKey, storeKey);
|
this.storeKeyMap.set(snapshotKey, storeKey);
|
||||||
|
|
||||||
|
// Emit the updated activity.
|
||||||
|
this.emit(`updated:${activity.key}`, { activity });
|
||||||
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
|
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
|
||||||
// If we kept the existing activity, delete the new activity from the store
|
// If we kept the existing activity, delete the new activity from the store
|
||||||
await this.store.set(storeKey, null);
|
// await this.store.set(storeKey, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,27 +269,27 @@ export class Activities {
|
|||||||
*/
|
*/
|
||||||
private getActivityFromSnapshot(
|
private getActivityFromSnapshot(
|
||||||
type: string,
|
type: string,
|
||||||
id: string,
|
entityId: string = '',
|
||||||
): Activity | undefined {
|
): Activity | undefined {
|
||||||
return this.snapshot[type]?.[id];
|
return this.snapshot[type]?.[entityId];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set an activity in the snapshot, creating necessary object structure
|
* Set an activity in the snapshot, creating necessary object structure
|
||||||
*/
|
*/
|
||||||
private setActivityInSnapshot(activity: Activity): void {
|
private setActivityInSnapshot(activity: Activity): void {
|
||||||
if (!this.snapshot[activity.name]) {
|
if (!this.snapshot[activity.key]) {
|
||||||
this.snapshot[activity.name] = {};
|
this.snapshot[activity.key] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.snapshot[activity.name][activity.entityId] = activity;
|
this.snapshot[activity.key][activity.entityId || ''] = activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the snapshot key for an activity
|
* Get the snapshot key for an activity
|
||||||
*/
|
*/
|
||||||
private getSnapshotKey(activity: Activity): string {
|
private getSnapshotKey(activity: Activity): string {
|
||||||
return `${activity.name}:${activity.entityId}`;
|
return `${activity.key}:${activity.entityId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,7 +299,7 @@ export class Activities {
|
|||||||
existingActivity: Activity | undefined,
|
existingActivity: Activity | undefined,
|
||||||
newActivity: Activity,
|
newActivity: Activity,
|
||||||
): Activity {
|
): Activity {
|
||||||
const policy = this.policies.get(newActivity.name);
|
const policy = this.policies.get(newActivity.key);
|
||||||
|
|
||||||
if (!policy) {
|
if (!policy) {
|
||||||
// Default policy: compare timestamps
|
// Default policy: compare timestamps
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseStore } from './base-store.js';
|
import { BaseStore } from './base-store.js';
|
||||||
|
import { StoreInMemory } from './store-inmemory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A store that accepts multiple stores and syncs them by key.
|
* A store that accepts multiple stores and syncs them by key.
|
||||||
@@ -12,127 +13,128 @@ import { BaseStore } from './base-store.js';
|
|||||||
*/
|
*/
|
||||||
export class StoreComposite extends BaseStore {
|
export class StoreComposite extends BaseStore {
|
||||||
static async from(
|
static async from(
|
||||||
localStore: BaseStore,
|
stores: Array<BaseStore> = [],
|
||||||
remoteStores: Array<BaseStore> = [],
|
primaryStore: BaseStore = StoreInMemory.from(),
|
||||||
) {
|
) {
|
||||||
const compositeStore = new this(localStore, remoteStores);
|
const compositeStore = new this(stores, primaryStore);
|
||||||
await compositeStore.sync();
|
await compositeStore.sync();
|
||||||
return compositeStore;
|
return compositeStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private localStore: BaseStore,
|
private stores: Array<BaseStore> = [],
|
||||||
private remoteStores: Array<BaseStore> = [],
|
private primaryStore: BaseStore,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async all<T>() {
|
async all<T>() {
|
||||||
return await this.localStore.all<T>();
|
return await this.primaryStore.all<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
async keys() {
|
async keys() {
|
||||||
return await this.localStore.keys();
|
return await this.primaryStore.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(key: string) {
|
async get<T>(key: string) {
|
||||||
return await this.localStore.get<T>(key);
|
return await this.primaryStore.get<T>(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T) {
|
async set<T>(key: string, value: T) {
|
||||||
await this.localStore.set(key, value);
|
await this.primaryStore.set(key, value);
|
||||||
this.emit('set', { key, value });
|
this.emit('set', { key, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string) {
|
async delete(key: string) {
|
||||||
await this.localStore.delete(key);
|
await this.primaryStore.delete(key);
|
||||||
this.emit('delete', { key });
|
this.emit('delete', { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
await this.localStore.clear();
|
await this.primaryStore.clear();
|
||||||
this.emit('clear', undefined);
|
this.emit('clear', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async has(key: string) {
|
async has(key: string) {
|
||||||
return await this.localStore.has(key);
|
return await this.primaryStore.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.localStore.close(),
|
this.primaryStore.close(),
|
||||||
...this.remoteStores.map((store) => store.close()),
|
...this.stores.map((store) => store.close()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new remote store and set up synchronization
|
* Add a new remote store and set up synchronization
|
||||||
*/
|
*/
|
||||||
public async addRemoteStore(remoteStore: BaseStore): Promise<void> {
|
async addStore(remoteStore: BaseStore): Promise<void> {
|
||||||
this.remoteStores.push(remoteStore);
|
this.stores.push(remoteStore);
|
||||||
|
|
||||||
// Propagate existing local events to the new remote
|
// Propagate existing local events to the new remote
|
||||||
this.localStore.on('set', async (payload) => {
|
this.primaryStore.on('set', async (payload) => {
|
||||||
|
if (await remoteStore.has(payload.key)) return;
|
||||||
await remoteStore.set(payload.key, payload.value);
|
await remoteStore.set(payload.key, payload.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.localStore.on('delete', async (payload) => {
|
this.primaryStore.on('delete', async (payload) => {
|
||||||
|
if (await remoteStore.has(payload.key)) {
|
||||||
await remoteStore.delete(payload.key);
|
await remoteStore.delete(payload.key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.localStore.on('clear', async () => {
|
this.primaryStore.on('clear', async () => {
|
||||||
await remoteStore.clear();
|
await remoteStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up remote to local propagation
|
// Set up remote to local propagation
|
||||||
remoteStore.on('set', async (payload) => {
|
remoteStore.on('set', async (payload) => {
|
||||||
if (await this.localStore.has(payload.key)) return;
|
if (await this.primaryStore.has(payload.key)) return;
|
||||||
await this.localStore.set(payload.key, payload.value);
|
await this.primaryStore.set(payload.key, payload.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
remoteStore.on('delete', async (payload) => {
|
remoteStore.on('delete', async (payload) => {
|
||||||
if (await this.localStore.has(payload.key)) {
|
if (await this.primaryStore.has(payload.key)) {
|
||||||
await this.localStore.delete(payload.key);
|
await this.primaryStore.delete(payload.key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
remoteStore.on('clear', async () => {
|
remoteStore.on('clear', async () => {
|
||||||
await this.localStore.clear();
|
await this.primaryStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync with the new remote
|
// Sync with the new remote
|
||||||
await this.syncWithRemote(remoteStore);
|
await this.syncStore(remoteStore);
|
||||||
}
|
|
||||||
|
|
||||||
private async syncWithRemote(remoteStore: BaseStore): Promise<void> {
|
|
||||||
const remoteKeys = await remoteStore.keys();
|
|
||||||
const localKeys = await this.localStore.keys();
|
|
||||||
|
|
||||||
const missingLocalKeys = remoteKeys.filter(
|
|
||||||
(key) => !localKeys.includes(key),
|
|
||||||
);
|
|
||||||
const missingRemoteKeys = localKeys.filter(
|
|
||||||
(key) => !remoteKeys.includes(key),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
...missingLocalKeys.map(async (key) => {
|
|
||||||
const value = await remoteStore.get(key);
|
|
||||||
await this.localStore.set(key, value);
|
|
||||||
}),
|
|
||||||
...missingRemoteKeys.map(async (key) => {
|
|
||||||
const value = await this.localStore.get(key);
|
|
||||||
await remoteStore.set(key, value);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(): Promise<void> {
|
async sync(): Promise<void> {
|
||||||
// Sync all remote stores in parallel
|
// Sync all remote stores in parallel
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.remoteStores.map(async (remoteStore) =>
|
this.stores.map(async (remoteStore) => this.syncStore(remoteStore)),
|
||||||
this.syncWithRemote(remoteStore),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncStore(store: BaseStore): Promise<void> {
|
||||||
|
const storeKeys = await store.keys();
|
||||||
|
const primaryStoreKeys = await this.primaryStore.keys();
|
||||||
|
|
||||||
|
const missingLocalKeys = storeKeys.filter(
|
||||||
|
(key) => !primaryStoreKeys.includes(key),
|
||||||
|
);
|
||||||
|
const missingRemoteKeys = primaryStoreKeys.filter(
|
||||||
|
(key) => !storeKeys.includes(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...missingLocalKeys.map(async (key) => {
|
||||||
|
const value = await store.get(key);
|
||||||
|
await this.set(key, value);
|
||||||
|
}),
|
||||||
|
...missingRemoteKeys.map(async (key) => {
|
||||||
|
const value = await this.primaryStore.get(key);
|
||||||
|
await store.set(key, value);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,55 @@ import { StoreInMemorySynced } from './store-inmemory-synced.js';
|
|||||||
|
|
||||||
import { Encrypter } from '@xocash/primitives';
|
import { Encrypter } from '@xocash/primitives';
|
||||||
|
|
||||||
// TODO: Only use pack/unpack on encrypted content.
|
// Only use pack/unpack on encrypted content.
|
||||||
// Unsure why you were doing it on non-encrypted content (as it's not needed for IDB).
|
// This ensures consistent handling of data whether encrypted or not.
|
||||||
// Did you bump into an object that couldn't be structuredClone'd?
|
|
||||||
import { pack, unpack } from 'msgpackr';
|
import { pack, unpack } from 'msgpackr';
|
||||||
|
|
||||||
import { openDB, deleteDB, IDBPDatabase } from 'idb';
|
import { openDB, deleteDB, IDBPDatabase } from 'idb';
|
||||||
|
|
||||||
export class StorageIndexedDB extends BaseStorage {
|
export class StorageIndexedDB extends BaseStorage {
|
||||||
private constructor(
|
private db: IDBPDatabase;
|
||||||
private db: IDBPDatabase,
|
private dbName: string;
|
||||||
private dbName: string,
|
private versionChangePromise: Promise<void> | null = null;
|
||||||
) {
|
private activeStores: Set<StoreIndexedDB> = new Set(); // Track all active stores
|
||||||
|
|
||||||
|
private constructor(db: IDBPDatabase, dbName: string) {
|
||||||
super();
|
super();
|
||||||
|
this.db = db;
|
||||||
|
this.dbName = dbName;
|
||||||
|
|
||||||
|
// Listen for version change events
|
||||||
|
this.db.addEventListener('versionchange', () => {
|
||||||
|
this.db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for any ongoing version changes to complete
|
||||||
|
private async waitForVersionChange(): Promise<void> {
|
||||||
|
if (this.versionChangePromise) {
|
||||||
|
await this.versionChangePromise;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async createOrOpen(dbName: string) {
|
public static async createOrOpen(dbName: string) {
|
||||||
try {
|
try {
|
||||||
// Check if database exists by attempting to open it at current version
|
// Check if database exists by attempting to open it at current version
|
||||||
const existingDb = await openDB(dbName, undefined);
|
const existingDb = await openDB(dbName, undefined, {
|
||||||
|
blocked() {
|
||||||
|
// Handle case where the database is blocked
|
||||||
|
console.warn(`Database ${dbName} is blocked, waiting...`);
|
||||||
|
},
|
||||||
|
blocking() {
|
||||||
|
// When this connection is blocking others
|
||||||
|
console.warn(`This connection is blocking others for ${dbName}`);
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
// When the database connection is terminated unexpectedly
|
||||||
|
console.error(
|
||||||
|
`Database connection to ${dbName} was terminated unexpectedly`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
return new StorageIndexedDB(existingDb, dbName);
|
return new StorageIndexedDB(existingDb, dbName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If database doesn't exist, create it at version 1
|
// If database doesn't exist, create it at version 1
|
||||||
@@ -35,33 +65,67 @@ export class StorageIndexedDB extends BaseStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listStores() {
|
async listStores() {
|
||||||
|
await this.waitForVersionChange();
|
||||||
return Array.from(this.db.objectStoreNames);
|
return Array.from(this.db.objectStoreNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStore(storeName: string, opts: StorageOpts = {}) {
|
async createStore(storeName: string, opts: StorageOpts = {}) {
|
||||||
|
await this.waitForVersionChange();
|
||||||
|
|
||||||
if (!this.db.objectStoreNames.contains(storeName)) {
|
if (!this.db.objectStoreNames.contains(storeName)) {
|
||||||
const currentVersion = this.db.version;
|
const currentVersion = this.db.version;
|
||||||
this.db.close();
|
|
||||||
|
|
||||||
const db = await openDB(this.dbName, currentVersion + 1, {
|
// Set up the version change promise
|
||||||
|
this.versionChangePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const newDb = await openDB(this.dbName, currentVersion + 1, {
|
||||||
upgrade(db) {
|
upgrade(db) {
|
||||||
db.createObjectStore(storeName);
|
db.createObjectStore(storeName);
|
||||||
},
|
},
|
||||||
|
blocked() {
|
||||||
|
console.warn(`Database upgrade is blocked, waiting...`);
|
||||||
|
},
|
||||||
|
blocking() {
|
||||||
|
console.warn(`This connection is blocking upgrades`);
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
console.error(
|
||||||
|
`Database connection was terminated unexpectedly during upgrade`,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.db = db;
|
|
||||||
|
// Update the database reference
|
||||||
|
this.db = newDb;
|
||||||
|
|
||||||
|
// Update db references in all active stores
|
||||||
|
for (const store of this.activeStores) {
|
||||||
|
store.updateDatabaseReference(newDb);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.versionChangePromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Wait for the version change to complete
|
||||||
|
await this.versionChangePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await StoreIndexedDB.create(
|
const store = await StoreIndexedDB.create(
|
||||||
this.db,
|
this.db,
|
||||||
storeName,
|
storeName,
|
||||||
opts.encryption,
|
opts.encryption,
|
||||||
|
this, // Pass a reference to the parent storage
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.activeStores.add(store); // Track this store
|
||||||
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
|
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStore(storeName: string, opts: StorageOpts = {}) {
|
async getStore(storeName: string, opts: StorageOpts = {}) {
|
||||||
try {
|
try {
|
||||||
|
await this.waitForVersionChange();
|
||||||
|
|
||||||
if (!this.db.objectStoreNames.contains(storeName)) {
|
if (!this.db.objectStoreNames.contains(storeName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,9 +134,13 @@ export class StorageIndexedDB extends BaseStorage {
|
|||||||
this.db,
|
this.db,
|
||||||
storeName,
|
storeName,
|
||||||
opts.encryption,
|
opts.encryption,
|
||||||
|
this, // Pass reference to parent
|
||||||
);
|
);
|
||||||
|
|
||||||
return opts.syncInMemory ? StoreInMemorySynced.create(store) : store;
|
this.activeStores.add(store); // Track this store
|
||||||
|
return opts.syncInMemory
|
||||||
|
? await StoreInMemorySynced.create(store)
|
||||||
|
: store;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -87,23 +155,60 @@ export class StorageIndexedDB extends BaseStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteStore(storeName: string) {
|
async deleteStore(storeName: string) {
|
||||||
|
await this.waitForVersionChange();
|
||||||
|
|
||||||
if (this.db.objectStoreNames.contains(storeName)) {
|
if (this.db.objectStoreNames.contains(storeName)) {
|
||||||
const currentVersion = this.db.version;
|
const currentVersion = this.db.version;
|
||||||
this.db.close();
|
|
||||||
|
|
||||||
const db = await openDB(this.dbName, currentVersion + 1, {
|
this.versionChangePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const newDb = await openDB(this.dbName, currentVersion + 1, {
|
||||||
upgrade(db) {
|
upgrade(db) {
|
||||||
db.deleteObjectStore(storeName);
|
db.deleteObjectStore(storeName);
|
||||||
},
|
},
|
||||||
|
blocked() {
|
||||||
|
console.warn(`Database upgrade is blocked, waiting...`);
|
||||||
|
},
|
||||||
|
blocking() {
|
||||||
|
console.warn(`This connection is blocking upgrades`);
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
console.error(
|
||||||
|
`Database connection was terminated unexpectedly during upgrade`,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.db = db;
|
|
||||||
|
this.db = newDb;
|
||||||
|
|
||||||
|
// Update db references in all active stores
|
||||||
|
for (const store of this.activeStores) {
|
||||||
|
store.updateDatabaseReference(newDb);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.versionChangePromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await this.versionChangePromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDatabase() {
|
async deleteDatabase() {
|
||||||
|
// Remove all active stores before closing
|
||||||
|
this.activeStores.clear();
|
||||||
|
|
||||||
|
// Close current connection
|
||||||
this.db.close();
|
this.db.close();
|
||||||
|
|
||||||
|
// Delete the database
|
||||||
await deleteDB(this.dbName);
|
await deleteDB(this.dbName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to remove store from tracking when it's closed
|
||||||
|
removeActiveStore(store: StoreIndexedDB) {
|
||||||
|
this.activeStores.delete(store);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,26 +219,24 @@ export class StoreIndexedDB extends BaseStore {
|
|||||||
public db: IDBPDatabase,
|
public db: IDBPDatabase,
|
||||||
public storeName: string,
|
public storeName: string,
|
||||||
public encryption?: Encrypter,
|
public encryption?: Encrypter,
|
||||||
|
private parentStorage?: StorageIndexedDB, // Reference to parent storage
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
// Method to update the database reference
|
||||||
this.broadcastChannel = new BroadcastChannel(
|
updateDatabaseReference(newDb: IDBPDatabase) {
|
||||||
`StoreIndexedDB:${db.name}.${this.storeName}`,
|
this.db = newDb;
|
||||||
);
|
|
||||||
|
|
||||||
this.broadcastChannel.onmessage = (message) => {
|
|
||||||
this.emit('set', message);
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new instance of StoreIndexedDB.
|
* Factory method to create a new instance of StoreIndexedDB.
|
||||||
* Provides a more convenient way to instantiate the store when working with promises.
|
* Provides a convenient way to instantiate the store when working with promises.
|
||||||
*
|
*
|
||||||
* @param db - An instance of an upgraded and opened IndexedDB database
|
* @param db - An instance of an upgraded and opened IndexedDB database
|
||||||
* @param storeName - The name of the object store within the database to use
|
* @param storeName - The name of the object store within the database to use
|
||||||
|
* @param encryption - Optional encrypter for encrypting/decrypting data
|
||||||
|
* @param parentStorage - Reference to the parent StorageIndexedDB instance
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
@@ -144,8 +247,9 @@ export class StoreIndexedDB extends BaseStore {
|
|||||||
db: IDBPDatabase,
|
db: IDBPDatabase,
|
||||||
storeName: string,
|
storeName: string,
|
||||||
encryption?: Encrypter,
|
encryption?: Encrypter,
|
||||||
|
parentStorage?: StorageIndexedDB,
|
||||||
) {
|
) {
|
||||||
return new this(db, storeName, encryption);
|
return new this(db, storeName, encryption, parentStorage);
|
||||||
}
|
}
|
||||||
|
|
||||||
async all<T>() {
|
async all<T>() {
|
||||||
@@ -173,6 +277,10 @@ export class StoreIndexedDB extends BaseStore {
|
|||||||
async get<T>(key: string) {
|
async get<T>(key: string) {
|
||||||
const encodedValue = await this.db.get(this.storeName, key);
|
const encodedValue = await this.db.get(this.storeName, key);
|
||||||
|
|
||||||
|
if (!encodedValue) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
const decryptedValue = this.encryption
|
const decryptedValue = this.encryption
|
||||||
? this.encryption.decrypt(encodedValue)
|
? this.encryption.decrypt(encodedValue)
|
||||||
: encodedValue;
|
: encodedValue;
|
||||||
@@ -221,7 +329,10 @@ export class StoreIndexedDB extends BaseStore {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(items).map(([key, value]) => {
|
Array.from(items).map(([key, value]) => {
|
||||||
const encodedValue = pack(value);
|
const encodedValue = pack(value);
|
||||||
return store.put(encodedValue, key);
|
const encryptedValue = this.encryption
|
||||||
|
? this.encryption.encrypt(encodedValue)
|
||||||
|
: encodedValue;
|
||||||
|
return store.put(encryptedValue, key);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,6 +356,10 @@ export class StoreIndexedDB extends BaseStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
this.db.close();
|
// Remove this store from parent's tracking
|
||||||
|
if (this.parentStorage) {
|
||||||
|
this.parentStorage.removeActiveStore(this);
|
||||||
|
}
|
||||||
|
// Note: We don't close the DB here as it's managed by StorageIndexedDB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { BaseStorage, BaseStore, StorageOpts } from './base-store.js';
|
||||||
|
import { StoreInMemorySynced } from './store-inmemory-synced.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bytes,
|
||||||
|
Encrypter,
|
||||||
|
encodeExtendedJson,
|
||||||
|
decodeExtendedJson,
|
||||||
|
} from '@xocash/primitives';
|
||||||
|
|
||||||
|
export class StorageLocalStorage extends BaseStorage {
|
||||||
|
private dbName: string;
|
||||||
|
private activeStores: Set<StoreLocalStorage> = new Set(); // Track all active stores
|
||||||
|
|
||||||
|
private constructor(dbName: string) {
|
||||||
|
super();
|
||||||
|
this.dbName = dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createOrOpen(dbName: string) {
|
||||||
|
return new StorageLocalStorage(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listStores() {
|
||||||
|
const prefix = `${this.dbName}.`;
|
||||||
|
const stores = new Set<string>();
|
||||||
|
|
||||||
|
// Iterate through localStorage to find all stores with our dbName prefix
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(prefix)) {
|
||||||
|
const parts = key.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
stores.add(parts[1]); // Extract store name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(stores);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStore(storeName: string, opts: StorageOpts = {}) {
|
||||||
|
// No need to explicitly create a store in localStorage, just return an instance
|
||||||
|
const store = await StoreLocalStorage.create(
|
||||||
|
this.dbName,
|
||||||
|
storeName,
|
||||||
|
opts.encryption,
|
||||||
|
this, // Pass a reference to the parent storage
|
||||||
|
);
|
||||||
|
|
||||||
|
this.activeStores.add(store); // Track this store
|
||||||
|
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStore(storeName: string, opts: StorageOpts = {}) {
|
||||||
|
try {
|
||||||
|
// Check if store exists by looking for any keys with this store's prefix
|
||||||
|
const storePrefix = `${this.dbName}.${storeName}.`;
|
||||||
|
let storeExists = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(storePrefix)) {
|
||||||
|
storeExists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeExists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await StoreLocalStorage.create(
|
||||||
|
this.dbName,
|
||||||
|
storeName,
|
||||||
|
opts.encryption,
|
||||||
|
this, // Pass reference to parent
|
||||||
|
);
|
||||||
|
|
||||||
|
this.activeStores.add(store); // Track this store
|
||||||
|
return opts.syncInMemory
|
||||||
|
? await StoreInMemorySynced.create(store)
|
||||||
|
: store;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrGetStore(storeName: string, opts: StorageOpts = {}) {
|
||||||
|
const existing = await this.getStore(storeName, opts);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return this.createStore(storeName, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStore(storeName: string) {
|
||||||
|
const storePrefix = `${this.dbName}.${storeName}.`;
|
||||||
|
|
||||||
|
// Collect all keys to remove
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(storePrefix)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all keys for this store
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDatabase() {
|
||||||
|
// Remove all active stores before closing
|
||||||
|
this.activeStores.clear();
|
||||||
|
|
||||||
|
// Remove all keys with this dbName prefix
|
||||||
|
const prefix = `${this.dbName}.`;
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(prefix)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to remove store from tracking when it's closed
|
||||||
|
removeActiveStore(store: StoreLocalStorage) {
|
||||||
|
this.activeStores.delete(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of BaseStore using LocalStorage as the storage backend.
|
||||||
|
*/
|
||||||
|
export class StoreLocalStorage extends BaseStore {
|
||||||
|
private keyPrefix: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dbName: string,
|
||||||
|
public storeName: string,
|
||||||
|
public encryption?: Encrypter,
|
||||||
|
private parentStorage?: StorageLocalStorage, // Reference to parent storage
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.keyPrefix = `${dbName}.${storeName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new instance of StoreLocalStorage.
|
||||||
|
* Provides a convenient way to instantiate the store when working with promises.
|
||||||
|
*
|
||||||
|
* @param dbName - The name of the database
|
||||||
|
* @param storeName - The name of the store within the database
|
||||||
|
* @param encryption - Optional encrypter for encrypting/decrypting data
|
||||||
|
* @param parentStorage - Reference to the parent StorageLocalStorage instance
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const store = await StoreLocalStorage.create('myDB', 'myStore');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
static async create(
|
||||||
|
dbName: string,
|
||||||
|
storeName: string,
|
||||||
|
encryption?: Encrypter,
|
||||||
|
parentStorage?: StorageLocalStorage,
|
||||||
|
) {
|
||||||
|
return new this(dbName, storeName, encryption, parentStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async all<T>() {
|
||||||
|
const result: { [key: string]: T } = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const fullKey = localStorage.key(i);
|
||||||
|
if (fullKey && fullKey.startsWith(this.keyPrefix)) {
|
||||||
|
const key = fullKey.substring(this.keyPrefix.length);
|
||||||
|
const value = await this.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys() {
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const fullKey = localStorage.key(i);
|
||||||
|
if (fullKey && fullKey.startsWith(this.keyPrefix)) {
|
||||||
|
keys.push(fullKey.substring(this.keyPrefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(key: string) {
|
||||||
|
const fullKey = this.keyPrefix + key;
|
||||||
|
const encodedValue = localStorage.getItem(fullKey);
|
||||||
|
|
||||||
|
if (!encodedValue) {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decryptedValue = this.encryption
|
||||||
|
? Bytes.from(await this.encryption.decrypt(encodedValue)).toUtf8()
|
||||||
|
: encodedValue;
|
||||||
|
|
||||||
|
return decodeExtendedJson(decryptedValue) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error retrieving value for key ${key}:`, error);
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T) {
|
||||||
|
const fullKey = this.keyPrefix + key;
|
||||||
|
const encodedValue = encodeExtendedJson(value);
|
||||||
|
|
||||||
|
const encryptedValue = this.encryption
|
||||||
|
? Bytes.from(await this.encryption.encrypt(encodedValue)).toHex()
|
||||||
|
: encodedValue;
|
||||||
|
|
||||||
|
// LocalStorage can only store strings, so we convert the binary data
|
||||||
|
localStorage.setItem(fullKey, encryptedValue);
|
||||||
|
this.emit('set', { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string) {
|
||||||
|
const fullKey = this.keyPrefix + key;
|
||||||
|
localStorage.removeItem(fullKey);
|
||||||
|
this.emit('delete', { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGet<T>(keys: string[]): Promise<Map<string, T>> {
|
||||||
|
const results = new Map<string, T>();
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = await this.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
results.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchSet<T>(items: Map<string, T>) {
|
||||||
|
for (const [key, value] of items) {
|
||||||
|
await this.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
const keys = await this.keys();
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.delete(key);
|
||||||
|
}
|
||||||
|
this.emit('clear', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string) {
|
||||||
|
const fullKey = this.keyPrefix + key;
|
||||||
|
return localStorage.getItem(fullKey) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
// Remove this store from parent's tracking
|
||||||
|
if (this.parentStorage) {
|
||||||
|
this.parentStorage.removeActiveStore(this);
|
||||||
|
}
|
||||||
|
// No need to close anything with localStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
import { type Activity } from '../storage/index.js';
|
import { type Activity } from '../storage/index.js';
|
||||||
|
|
||||||
|
export type WalletActivities =
|
||||||
|
| WalletName
|
||||||
|
| WalletDescription
|
||||||
|
| WalletHDAddressGap
|
||||||
|
| TransactionFirstSeen
|
||||||
|
| TransactionMemo
|
||||||
|
| XOWalletColor
|
||||||
|
| XOTransactionCategory
|
||||||
|
| XOFrozenCoin;
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Standardized Activities
|
// Standardized Activities
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface WalletName extends Activity {
|
export interface WalletName extends Activity {
|
||||||
name: 'WalletName';
|
key: 'WalletName';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletDescription extends Activity {
|
export interface WalletDescription extends Activity {
|
||||||
name: 'WalletDescription';
|
key: 'WalletDescription';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletHDAddressGap extends Activity {
|
export interface WalletHDAddressGap extends Activity {
|
||||||
name: 'WalletHDAddressGap';
|
key: 'WalletHDAddressGap';
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionFirstSeen extends Activity {
|
export interface TransactionFirstSeen extends Activity {
|
||||||
name: 'TransactionFirstSeen';
|
key: 'TransactionFirstSeen';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionMemo extends Activity {
|
export interface TransactionMemo extends Activity {
|
||||||
name: 'TransactionMemo';
|
key: 'TransactionMemo';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,16 +44,16 @@ export interface TransactionMemo extends Activity {
|
|||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface XOWalletColor extends Activity {
|
export interface XOWalletColor extends Activity {
|
||||||
name: 'XOWalletColor';
|
key: 'XOWalletColor';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XOTransactionCategory extends Activity {
|
export interface XOTransactionCategory extends Activity {
|
||||||
name: 'XOTransactionCategory';
|
key: 'XOTransactionCategory';
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XOFrozenCoin extends Activity {
|
export interface XOFrozenCoin extends Activity {
|
||||||
name: 'XOFrozenCoin';
|
key: 'XOFrozenCoin';
|
||||||
value: boolean;
|
value: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
WalletUTXOs,
|
||||||
type InputTemplateBCH,
|
type InputTemplateBCH,
|
||||||
type TransactionTemplate,
|
type TransactionTemplate,
|
||||||
type WalletAddresses,
|
type WalletAddresses,
|
||||||
|
type WalletBlockchain,
|
||||||
type WalletTransactions,
|
type WalletTransactions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
import { type BlockchainUTXOs } from '../blockchain/index.js';
|
import { type BlockchainUTXOs } from '../blockchain/index.js';
|
||||||
import { type Activities } from '../storage/index.js';
|
import { type Activities, type BaseStore } from '../storage/index.js';
|
||||||
import { EventEmitter } from '../utils/index.js';
|
import { EventEmitter } from '../utils/index.js';
|
||||||
|
|
||||||
import { type TokenBalances, type Address } from '@xocash/primitives';
|
import { type TokenBalances, type Address } from '@xocash/primitives';
|
||||||
|
|
||||||
export type WalletEvents = {
|
export type WalletEvents = {
|
||||||
shouldMonitorUpdated: boolean;
|
shouldMonitorUpdated: boolean;
|
||||||
|
addressesUpdated: WalletAddresses;
|
||||||
transactionsUpdated: WalletTransactions;
|
transactionsUpdated: WalletTransactions;
|
||||||
unspentsUpdated: BlockchainUTXOs;
|
unspentsUpdated: BlockchainUTXOs;
|
||||||
stateUpdated: string | null;
|
stateUpdated: string | null;
|
||||||
@@ -30,6 +33,13 @@ export type WalletEvents = {
|
|||||||
export abstract class BaseWallet<
|
export abstract class BaseWallet<
|
||||||
WalletEventsType extends WalletEvents = WalletEvents,
|
WalletEventsType extends WalletEvents = WalletEvents,
|
||||||
> extends EventEmitter<WalletEventsType> {
|
> extends EventEmitter<WalletEventsType> {
|
||||||
|
static readonly deriveKey: (keyName: string) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
// Dependencies.
|
||||||
|
abstract readonly activities: Activities;
|
||||||
|
abstract readonly blockchain: WalletBlockchain;
|
||||||
|
abstract readonly cache: BaseStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID for this wallet.
|
* Get the ID for this wallet.
|
||||||
*/
|
*/
|
||||||
@@ -50,11 +60,6 @@ export abstract class BaseWallet<
|
|||||||
*/
|
*/
|
||||||
abstract getSigningKey(): Promise<Uint8Array>;
|
abstract getSigningKey(): Promise<Uint8Array>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the activities implementation
|
|
||||||
*/
|
|
||||||
abstract getActivities(): Promise<Activities>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start monitoring this wallet for any transaction activity.
|
* Start monitoring this wallet for any transaction activity.
|
||||||
*/
|
*/
|
||||||
@@ -109,7 +114,7 @@ export abstract class BaseWallet<
|
|||||||
* May as well just getUnspentDirectives() from the local state instead of fetch.
|
* May as well just getUnspentDirectives() from the local state instead of fetch.
|
||||||
* Likewise for balances.
|
* Likewise for balances.
|
||||||
*/
|
*/
|
||||||
abstract getUnspentDirectives(): Promise<Array<InputTemplateBCH>>;
|
abstract getUnspentDirectives(): Promise<WalletUTXOs>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the satoshi balance of this wallet.
|
* Get the satoshi balance of this wallet.
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type WalletUTXO = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WalletUTXOs = ExtMap<WalletUTXO>;
|
||||||
|
|
||||||
export type TransactionTemplate = TransactionTemplateFixed<CompilerBCH>;
|
export type TransactionTemplate = TransactionTemplateFixed<CompilerBCH>;
|
||||||
|
|
||||||
export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>;
|
export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>;
|
||||||
|
|||||||
@@ -59,22 +59,24 @@ export type WalletHDGenesisData = {
|
|||||||
export class WalletHD extends BaseWallet {
|
export class WalletHD extends BaseWallet {
|
||||||
static async from<T extends typeof WalletHD>(
|
static async from<T extends typeof WalletHD>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletDependencies,
|
deps: WalletDependencies,
|
||||||
genesisData: WalletHDGenesisData,
|
genesisData: WalletHDGenesisData,
|
||||||
) {
|
) {
|
||||||
// Assign in memory stores as defaults in case otherwise not provided.
|
// Assign defaults if not otherwise provideed.
|
||||||
const servicesFinal = {
|
const activities = deps.activities
|
||||||
...{
|
? deps.activities
|
||||||
// Default to an in-memory-only Activity store.
|
: await Activities.from();
|
||||||
activities: await Activities.fromStores(StoreInMemory.from()),
|
const cache = deps.cache ? deps.cache : StoreInMemory.from();
|
||||||
store: new StoreInMemory(),
|
|
||||||
cache: new StoreInMemory(),
|
// Assign our final dependencies.
|
||||||
},
|
const depsFinal = {
|
||||||
...services,
|
activities,
|
||||||
|
cache,
|
||||||
|
blockchain: deps.blockchain,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Return a new instance of WalletHD.
|
// Return a new instance of WalletHD.
|
||||||
return new this(servicesFinal, genesisData) as InstanceType<T>;
|
return new this(depsFinal, genesisData) as InstanceType<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependencies.
|
// Dependencies.
|
||||||
@@ -158,10 +160,6 @@ export class WalletHD extends BaseWallet {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivities(): Promise<Activities> {
|
|
||||||
return this.activities;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
// Get the address gap.
|
// Get the address gap.
|
||||||
// TODO: This should come from activities.
|
// TODO: This should come from activities.
|
||||||
@@ -263,7 +261,7 @@ export class WalletHD extends BaseWallet {
|
|||||||
const unspentDirectives = await Promise.all(fetchPromises);
|
const unspentDirectives = await Promise.all(fetchPromises);
|
||||||
|
|
||||||
// Flatten them.
|
// Flatten them.
|
||||||
const unspentsFlattened = unspentDirectives.flat();
|
const unspentsFlattened = ExtMap.flatten(unspentDirectives);
|
||||||
|
|
||||||
// Return the unspents.
|
// Return the unspents.
|
||||||
return unspentsFlattened;
|
return unspentsFlattened;
|
||||||
@@ -358,7 +356,7 @@ export class WalletHD extends BaseWallet {
|
|||||||
// Build the transaction.
|
// Build the transaction.
|
||||||
const tx = TransactionBuilder.build(
|
const tx = TransactionBuilder.build(
|
||||||
txTemplateFinal,
|
txTemplateFinal,
|
||||||
unspents,
|
unspents.toArray(),
|
||||||
changeLockingBytecode,
|
changeLockingBytecode,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -398,6 +396,7 @@ export class WalletHD extends BaseWallet {
|
|||||||
// Create a child P2PKH wallet for this Private Key.
|
// Create a child P2PKH wallet for this Private Key.
|
||||||
const wallet = await WalletP2PKH.from(
|
const wallet = await WalletP2PKH.from(
|
||||||
{
|
{
|
||||||
|
activities: this.activities,
|
||||||
blockchain: this.blockchain,
|
blockchain: this.blockchain,
|
||||||
cache: this.cache,
|
cache: this.cache,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,20 +46,27 @@ export type WalletP2PKHGenesisData = {
|
|||||||
export class WalletP2PKH extends BaseWallet {
|
export class WalletP2PKH extends BaseWallet {
|
||||||
static async from<T extends typeof WalletP2PKH>(
|
static async from<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
dependencies: WalletDependencies,
|
deps: WalletDependencies,
|
||||||
genesisData: WalletP2PKHGenesisData,
|
genesisData: WalletP2PKHGenesisData,
|
||||||
) {
|
) {
|
||||||
// Assign in memory stores as defaults in case otherwise not provided.
|
// Assign defaults if not otherwise provideed.
|
||||||
const dependenciesFinal = {
|
const activities = deps.activities
|
||||||
...{
|
? deps.activities
|
||||||
activities: await Activities.fromStores(StoreInMemory.from()),
|
: await Activities.from();
|
||||||
cache: new StoreInMemory(),
|
const cache = deps.cache ? deps.cache : StoreInMemory.from();
|
||||||
},
|
|
||||||
...dependencies,
|
// Assign our final dependencies.
|
||||||
|
const depsFinal = {
|
||||||
|
activities,
|
||||||
|
cache,
|
||||||
|
blockchain: deps.blockchain,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Return a new instance of Wallet P2PKH.
|
// Return a new instance of Wallet P2PKH.
|
||||||
return new this(dependenciesFinal, genesisData) as InstanceType<T>;
|
const wallet = new this(depsFinal, genesisData) as InstanceType<T>;
|
||||||
|
|
||||||
|
// Return the wallet instance.
|
||||||
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromBytes<T extends typeof WalletP2PKH>(
|
static fromBytes<T extends typeof WalletP2PKH>(
|
||||||
@@ -188,10 +195,6 @@ export class WalletP2PKH extends BaseWallet {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivities(): Promise<Activities> {
|
|
||||||
return this.activities;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
// Do nothing if already started.
|
// Do nothing if already started.
|
||||||
if (this.isStarted) {
|
if (this.isStarted) {
|
||||||
@@ -314,8 +317,7 @@ export class WalletP2PKH extends BaseWallet {
|
|||||||
const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd);
|
const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd);
|
||||||
|
|
||||||
// Transform our inputs into unlocking directives.
|
// Transform our inputs into unlocking directives.
|
||||||
const inputDirectives = unspentOutputs
|
const inputDirectives = unspentOutputs.map((blockchainUTXO) => ({
|
||||||
.map((blockchainUTXO) => ({
|
|
||||||
outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex,
|
outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex,
|
||||||
outpointTransactionHash:
|
outpointTransactionHash:
|
||||||
blockchainUTXO.utxo.outpoint.outpointTransactionHash,
|
blockchainUTXO.utxo.outpoint.outpointTransactionHash,
|
||||||
@@ -329,8 +331,7 @@ export class WalletP2PKH extends BaseWallet {
|
|||||||
token: blockchainUTXO.utxo.output.token,
|
token: blockchainUTXO.utxo.output.token,
|
||||||
valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis,
|
valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
.toArray();
|
|
||||||
|
|
||||||
// Return the directives.
|
// Return the directives.
|
||||||
return inputDirectives;
|
return inputDirectives;
|
||||||
@@ -397,7 +398,7 @@ export class WalletP2PKH extends BaseWallet {
|
|||||||
// Build the transaction.
|
// Build the transaction.
|
||||||
const tx = TransactionBuilder.build(
|
const tx = TransactionBuilder.build(
|
||||||
txTemplateFinal,
|
txTemplateFinal,
|
||||||
unspents,
|
unspents.toArray(),
|
||||||
changeLockingBytecode,
|
changeLockingBytecode,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { describe, test, expect, beforeEach } from 'vitest';
|
|||||||
import { Activities, StoreInMemory } from '../../src/storage';
|
import { Activities, StoreInMemory } from '../../src/storage';
|
||||||
|
|
||||||
describe('Activities', () => {
|
describe('Activities', () => {
|
||||||
let store: StoreInMemory;
|
|
||||||
let activities: Activities;
|
let activities: Activities;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
activities = await Activities.fromStores(StoreInMemory.from());
|
activities = await Activities.from();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add activity with auto-generated timestamp', async () => {
|
test('should add activity with auto-generated timestamp', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
@@ -28,7 +27,7 @@ describe('Activities', () => {
|
|||||||
const timestamp = BigInt(1234567890000);
|
const timestamp = BigInt(1234567890000);
|
||||||
const activity = {
|
const activity = {
|
||||||
timestamp,
|
timestamp,
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
@@ -45,14 +44,14 @@ describe('Activities', () => {
|
|||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: oldTimestamp,
|
timestamp: oldTimestamp,
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Old Name',
|
value: 'Old Name',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: newTimestamp,
|
timestamp: newTimestamp,
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'New Name',
|
value: 'New Name',
|
||||||
});
|
});
|
||||||
@@ -73,14 +72,14 @@ describe('Activities', () => {
|
|||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: oldTimestamp,
|
timestamp: oldTimestamp,
|
||||||
name: 'TransactionFirstSeen',
|
key: 'TransactionFirstSeen',
|
||||||
entityId: 'tx1',
|
entityId: 'tx1',
|
||||||
value: 'First Value',
|
value: 'First Value',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: newTimestamp,
|
timestamp: newTimestamp,
|
||||||
name: 'TransactionFirstSeen',
|
key: 'TransactionFirstSeen',
|
||||||
entityId: 'tx1',
|
entityId: 'tx1',
|
||||||
value: 'Second Value',
|
value: 'Second Value',
|
||||||
});
|
});
|
||||||
@@ -100,14 +99,14 @@ describe('Activities', () => {
|
|||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
name: 'CustomType',
|
key: 'CustomType',
|
||||||
entityId: 'custom1',
|
entityId: 'custom1',
|
||||||
value: 50,
|
value: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
name: 'CustomType',
|
key: 'CustomType',
|
||||||
entityId: 'custom1',
|
entityId: 'custom1',
|
||||||
value: 30,
|
value: 30,
|
||||||
});
|
});
|
||||||
@@ -121,19 +120,19 @@ describe('Activities', () => {
|
|||||||
const testActivities = [
|
const testActivities = [
|
||||||
{
|
{
|
||||||
timestamp: BigInt(3000),
|
timestamp: BigInt(3000),
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 3',
|
value: 'Name 3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 1',
|
value: 'Name 1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 2',
|
value: 'Name 2',
|
||||||
},
|
},
|
||||||
@@ -151,14 +150,14 @@ describe('Activities', () => {
|
|||||||
test('should handle multiple activity types independently', async () => {
|
test('should handle multiple activity types independently', async () => {
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'My Wallet',
|
value: 'My Wallet',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.add({
|
await activities.add({
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
name: 'WalletDescription',
|
key: 'WalletDescription',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'My Description',
|
value: 'My Description',
|
||||||
});
|
});
|
||||||
@@ -172,7 +171,7 @@ describe('Activities', () => {
|
|||||||
test('should update snapshot immediately when activity is added', async () => {
|
test('should update snapshot immediately when activity is added', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
@@ -186,7 +185,7 @@ describe('Activities', () => {
|
|||||||
|
|
||||||
test('should handle missing timestamp gracefully', async () => {
|
test('should handle missing timestamp gracefully', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
name: 'WalletName',
|
key: 'WalletName',
|
||||||
entityId: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user