756 lines
21 KiB
TypeScript
756 lines
21 KiB
TypeScript
/**
|
|
* Plaid Local Learning System
|
|
*
|
|
* A privacy-preserving financial learning system that runs entirely in the browser.
|
|
* No financial data, learning patterns, or AI models ever leave the client device.
|
|
*
|
|
* ## Architecture
|
|
*
|
|
* ```
|
|
* ┌─────────────────────────────────────────────────────────────────────┐
|
|
* │ BROWSER (All Data Stays Here) │
|
|
* │ │
|
|
* │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
|
* │ │ Plaid Link │────▶│ Transaction │────▶│ Local Learning │ │
|
|
* │ │ (OAuth) │ │ Processor │ │ Engine (WASM) │ │
|
|
* │ └─────────────┘ └──────────────┘ └───────────────────┘ │
|
|
* │ │ │ │ │
|
|
* │ ▼ ▼ ▼ │
|
|
* │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
|
* │ │ IndexedDB │ │ IndexedDB │ │ IndexedDB │ │
|
|
* │ │ (Tokens) │ │ (Embeddings) │ │ (Q-Values) │ │
|
|
* │ └─────────────┘ └──────────────┘ └───────────────────┘ │
|
|
* │ │
|
|
* │ ┌─────────────────────────────────────────────────────────────┐ │
|
|
* │ │ RuVector WASM Engine │ │
|
|
* │ │ • HNSW Vector Index (150x faster similarity search) │ │
|
|
* │ │ • Spiking Neural Network (temporal pattern learning) │ │
|
|
* │ │ • Q-Learning (spending optimization) │ │
|
|
* │ │ • LSH (semantic categorization) │ │
|
|
* │ └─────────────────────────────────────────────────────────────┘ │
|
|
* └─────────────────────────────────────────────────────────────────────┘
|
|
* ```
|
|
*
|
|
* ## Privacy Guarantees
|
|
*
|
|
* 1. Financial data NEVER leaves the browser
|
|
* 2. Learning happens 100% client-side in WASM
|
|
* 3. Optional encryption for IndexedDB storage
|
|
* 4. No analytics, telemetry, or tracking
|
|
* 5. User can delete all data instantly
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { PlaidLocalLearner } from './plaid-local-learner';
|
|
*
|
|
* const learner = new PlaidLocalLearner();
|
|
* await learner.init();
|
|
*
|
|
* // Process transactions (stays in browser)
|
|
* const insights = await learner.processTransactions(transactions);
|
|
*
|
|
* // Get predictions (computed locally)
|
|
* const category = await learner.predictCategory(newTransaction);
|
|
* const anomaly = await learner.detectAnomaly(newTransaction);
|
|
*
|
|
* // All data persisted to IndexedDB
|
|
* await learner.save();
|
|
* ```
|
|
*/
|
|
|
|
import init, {
|
|
PlaidLocalLearner as WasmLearner,
|
|
WasmHnswIndex,
|
|
WasmCrypto,
|
|
WasmSpikingNetwork,
|
|
} from './ruvector_edge';
|
|
|
|
// Database constants
|
|
const DB_NAME = 'plaid_local_learning';
|
|
const DB_VERSION = 1;
|
|
const STORES = {
|
|
STATE: 'learning_state',
|
|
TOKENS: 'plaid_tokens',
|
|
TRANSACTIONS: 'transactions',
|
|
INSIGHTS: 'insights',
|
|
};
|
|
|
|
/**
|
|
* Transaction from Plaid API
|
|
*/
|
|
export interface Transaction {
|
|
transaction_id: string;
|
|
account_id: string;
|
|
amount: number;
|
|
date: string;
|
|
name: string;
|
|
merchant_name?: string;
|
|
category: string[];
|
|
pending: boolean;
|
|
payment_channel: string;
|
|
}
|
|
|
|
/**
|
|
* Spending pattern learned from transactions
|
|
*/
|
|
export interface SpendingPattern {
|
|
pattern_id: string;
|
|
category: string;
|
|
avg_amount: number;
|
|
frequency_days: number;
|
|
confidence: number;
|
|
last_seen: number;
|
|
}
|
|
|
|
/**
|
|
* Category prediction result
|
|
*/
|
|
export interface CategoryPrediction {
|
|
category: string;
|
|
confidence: number;
|
|
similar_transactions: string[];
|
|
}
|
|
|
|
/**
|
|
* Anomaly detection result
|
|
*/
|
|
export interface AnomalyResult {
|
|
is_anomaly: boolean;
|
|
anomaly_score: number;
|
|
reason: string;
|
|
expected_amount: number;
|
|
}
|
|
|
|
/**
|
|
* Budget recommendation
|
|
*/
|
|
export interface BudgetRecommendation {
|
|
category: string;
|
|
recommended_limit: number;
|
|
current_avg: number;
|
|
trend: 'increasing' | 'stable' | 'decreasing';
|
|
confidence: number;
|
|
}
|
|
|
|
/**
|
|
* Processing insights from batch
|
|
*/
|
|
export interface ProcessingInsights {
|
|
transactions_processed: number;
|
|
total_amount: number;
|
|
patterns_learned: number;
|
|
state_version: number;
|
|
}
|
|
|
|
/**
|
|
* Learning statistics
|
|
*/
|
|
export interface LearningStats {
|
|
version: number;
|
|
patterns_count: number;
|
|
q_values_count: number;
|
|
embeddings_count: number;
|
|
index_size: number;
|
|
}
|
|
|
|
/**
|
|
* Temporal spending heatmap
|
|
*/
|
|
export interface TemporalHeatmap {
|
|
day_of_week: number[]; // 7 values (Sun-Sat)
|
|
day_of_month: number[]; // 31 values
|
|
}
|
|
|
|
/**
|
|
* Plaid Link configuration
|
|
*/
|
|
export interface PlaidConfig {
|
|
clientId?: string;
|
|
environment: 'sandbox' | 'development' | 'production';
|
|
products: string[];
|
|
countryCodes: string[];
|
|
language: string;
|
|
}
|
|
|
|
/**
|
|
* Browser-local financial learning engine
|
|
*
|
|
* All data processing happens in the browser using WebAssembly.
|
|
* Financial data is never transmitted to any server.
|
|
*/
|
|
export class PlaidLocalLearner {
|
|
private wasmLearner: WasmLearner | null = null;
|
|
private db: IDBDatabase | null = null;
|
|
private initialized = false;
|
|
private encryptionKey: CryptoKey | null = null;
|
|
|
|
/**
|
|
* Initialize the local learner
|
|
*
|
|
* - Loads WASM module
|
|
* - Opens IndexedDB
|
|
* - Restores previous learning state
|
|
*/
|
|
async init(encryptionPassword?: string): Promise<void> {
|
|
if (this.initialized) return;
|
|
|
|
// Initialize WASM
|
|
await init();
|
|
|
|
// Create WASM learner
|
|
this.wasmLearner = new WasmLearner();
|
|
|
|
// Open IndexedDB
|
|
this.db = await this.openDatabase();
|
|
|
|
// Setup encryption if password provided
|
|
if (encryptionPassword) {
|
|
this.encryptionKey = await this.deriveKey(encryptionPassword);
|
|
}
|
|
|
|
// Load previous state
|
|
await this.load();
|
|
|
|
this.initialized = true;
|
|
console.log('🧠 PlaidLocalLearner initialized (100% browser-local)');
|
|
}
|
|
|
|
/**
|
|
* Open IndexedDB database
|
|
*/
|
|
private openDatabase(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
|
|
// Create object stores
|
|
if (!db.objectStoreNames.contains(STORES.STATE)) {
|
|
db.createObjectStore(STORES.STATE);
|
|
}
|
|
if (!db.objectStoreNames.contains(STORES.TOKENS)) {
|
|
db.createObjectStore(STORES.TOKENS);
|
|
}
|
|
if (!db.objectStoreNames.contains(STORES.TRANSACTIONS)) {
|
|
const store = db.createObjectStore(STORES.TRANSACTIONS, {
|
|
keyPath: 'transaction_id',
|
|
});
|
|
store.createIndex('date', 'date');
|
|
store.createIndex('category', 'category', { multiEntry: true });
|
|
}
|
|
if (!db.objectStoreNames.contains(STORES.INSIGHTS)) {
|
|
db.createObjectStore(STORES.INSIGHTS);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Derive encryption key from password
|
|
*
|
|
* Uses a unique salt per installation stored in IndexedDB.
|
|
* This prevents rainbow table attacks across different users.
|
|
*/
|
|
private async deriveKey(password: string): Promise<CryptoKey> {
|
|
const encoder = new TextEncoder();
|
|
|
|
// Get or create unique salt for this installation
|
|
const salt = await this.getOrCreateSalt();
|
|
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(password),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt,
|
|
iterations: 100000,
|
|
hash: 'SHA-256',
|
|
},
|
|
keyMaterial,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get or create a unique salt for this installation
|
|
*
|
|
* Salt is stored in IndexedDB and persists across sessions.
|
|
* Each browser/device gets a unique salt.
|
|
*/
|
|
private async getOrCreateSalt(): Promise<Uint8Array> {
|
|
const SALT_KEY = '_encryption_salt';
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.STATE], 'readwrite');
|
|
const store = transaction.objectStore(STORES.STATE);
|
|
|
|
// Try to get existing salt
|
|
const getRequest = store.get(SALT_KEY);
|
|
|
|
getRequest.onsuccess = () => {
|
|
if (getRequest.result) {
|
|
// Use existing salt
|
|
resolve(new Uint8Array(getRequest.result));
|
|
} else {
|
|
// Generate new random salt (32 bytes)
|
|
const newSalt = crypto.getRandomValues(new Uint8Array(32));
|
|
|
|
// Store it for future use
|
|
const putRequest = store.put(newSalt.buffer, SALT_KEY);
|
|
putRequest.onsuccess = () => resolve(newSalt);
|
|
putRequest.onerror = () => reject(putRequest.error);
|
|
}
|
|
};
|
|
|
|
getRequest.onerror = () => reject(getRequest.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Encrypt data for storage
|
|
*/
|
|
private async encrypt(data: string): Promise<ArrayBuffer> {
|
|
if (!this.encryptionKey) {
|
|
return new TextEncoder().encode(data);
|
|
}
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
this.encryptionKey,
|
|
new TextEncoder().encode(data)
|
|
);
|
|
|
|
// Prepend IV to encrypted data
|
|
const result = new Uint8Array(iv.length + encrypted.byteLength);
|
|
result.set(iv);
|
|
result.set(new Uint8Array(encrypted), iv.length);
|
|
return result.buffer;
|
|
}
|
|
|
|
/**
|
|
* Decrypt data from storage
|
|
*/
|
|
private async decrypt(data: ArrayBuffer): Promise<string> {
|
|
if (!this.encryptionKey) {
|
|
return new TextDecoder().decode(data);
|
|
}
|
|
|
|
const dataArray = new Uint8Array(data);
|
|
const iv = dataArray.slice(0, 12);
|
|
const encrypted = dataArray.slice(12);
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
this.encryptionKey,
|
|
encrypted
|
|
);
|
|
|
|
return new TextDecoder().decode(decrypted);
|
|
}
|
|
|
|
/**
|
|
* Save learning state to IndexedDB
|
|
*/
|
|
async save(): Promise<void> {
|
|
this.ensureInitialized();
|
|
|
|
const stateJson = this.wasmLearner!.saveState();
|
|
const encrypted = await this.encrypt(stateJson);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.STATE], 'readwrite');
|
|
const store = transaction.objectStore(STORES.STATE);
|
|
const request = store.put(encrypted, 'main');
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load learning state from IndexedDB
|
|
*/
|
|
async load(): Promise<void> {
|
|
this.ensureInitialized();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.STATE], 'readonly');
|
|
const store = transaction.objectStore(STORES.STATE);
|
|
const request = store.get('main');
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = async () => {
|
|
if (request.result) {
|
|
try {
|
|
const stateJson = await this.decrypt(request.result);
|
|
this.wasmLearner!.loadState(stateJson);
|
|
} catch (e) {
|
|
console.warn('Failed to load state, starting fresh:', e);
|
|
}
|
|
}
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a batch of transactions
|
|
*
|
|
* All processing happens locally in WASM. No data is transmitted.
|
|
*/
|
|
async processTransactions(transactions: Transaction[]): Promise<ProcessingInsights> {
|
|
this.ensureInitialized();
|
|
|
|
// Store transactions locally
|
|
await this.storeTransactions(transactions);
|
|
|
|
// Process in WASM
|
|
const insights = this.wasmLearner!.processTransactions(
|
|
JSON.stringify(transactions)
|
|
) as ProcessingInsights;
|
|
|
|
// Auto-save state
|
|
await this.save();
|
|
|
|
return insights;
|
|
}
|
|
|
|
/**
|
|
* Store transactions in IndexedDB
|
|
*/
|
|
private async storeTransactions(transactions: Transaction[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readwrite');
|
|
const store = transaction.objectStore(STORES.TRANSACTIONS);
|
|
|
|
transactions.forEach((tx) => {
|
|
store.put(tx);
|
|
});
|
|
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Predict category for a transaction
|
|
*/
|
|
predictCategory(transaction: Transaction): CategoryPrediction {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.predictCategory(
|
|
JSON.stringify(transaction)
|
|
) as CategoryPrediction;
|
|
}
|
|
|
|
/**
|
|
* Detect if a transaction is anomalous
|
|
*/
|
|
detectAnomaly(transaction: Transaction): AnomalyResult {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.detectAnomaly(
|
|
JSON.stringify(transaction)
|
|
) as AnomalyResult;
|
|
}
|
|
|
|
/**
|
|
* Get budget recommendation for a category
|
|
*/
|
|
getBudgetRecommendation(
|
|
category: string,
|
|
currentSpending: number,
|
|
budget: number
|
|
): BudgetRecommendation {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.getBudgetRecommendation(
|
|
category,
|
|
currentSpending,
|
|
budget
|
|
) as BudgetRecommendation;
|
|
}
|
|
|
|
/**
|
|
* Record spending outcome for Q-learning
|
|
*
|
|
* @param category - Spending category
|
|
* @param action - 'under_budget', 'at_budget', or 'over_budget'
|
|
* @param reward - Reward value (-1 to 1)
|
|
*/
|
|
recordOutcome(
|
|
category: string,
|
|
action: 'under_budget' | 'at_budget' | 'over_budget',
|
|
reward: number
|
|
): void {
|
|
this.ensureInitialized();
|
|
this.wasmLearner!.recordOutcome(category, action, reward);
|
|
}
|
|
|
|
/**
|
|
* Get all learned spending patterns
|
|
*/
|
|
getPatterns(): SpendingPattern[] {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.getPatternsSummary() as SpendingPattern[];
|
|
}
|
|
|
|
/**
|
|
* Get temporal spending heatmap
|
|
*/
|
|
getTemporalHeatmap(): TemporalHeatmap {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.getTemporalHeatmap() as TemporalHeatmap;
|
|
}
|
|
|
|
/**
|
|
* Find similar transactions
|
|
*/
|
|
findSimilar(transaction: Transaction, k: number = 5): { id: string; distance: number }[] {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.findSimilarTransactions(
|
|
JSON.stringify(transaction),
|
|
k
|
|
) as { id: string; distance: number }[];
|
|
}
|
|
|
|
/**
|
|
* Get learning statistics
|
|
*/
|
|
getStats(): LearningStats {
|
|
this.ensureInitialized();
|
|
return this.wasmLearner!.getStats() as LearningStats;
|
|
}
|
|
|
|
/**
|
|
* Clear all learned data
|
|
*
|
|
* Privacy feature: completely wipes all local learning data.
|
|
*/
|
|
async clearAllData(): Promise<void> {
|
|
this.ensureInitialized();
|
|
|
|
// Clear WASM state
|
|
this.wasmLearner!.clear();
|
|
|
|
// Clear IndexedDB
|
|
const stores = [STORES.STATE, STORES.TRANSACTIONS, STORES.INSIGHTS];
|
|
|
|
for (const storeName of stores) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = this.db!.transaction([storeName], 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
const request = store.clear();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
console.log('🗑️ All local learning data cleared');
|
|
}
|
|
|
|
/**
|
|
* Get stored transactions from IndexedDB
|
|
*/
|
|
async getStoredTransactions(
|
|
options: {
|
|
startDate?: string;
|
|
endDate?: string;
|
|
category?: string;
|
|
limit?: number;
|
|
} = {}
|
|
): Promise<Transaction[]> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readonly');
|
|
const store = transaction.objectStore(STORES.TRANSACTIONS);
|
|
|
|
let request: IDBRequest;
|
|
|
|
if (options.startDate && options.endDate) {
|
|
const index = store.index('date');
|
|
request = index.getAll(IDBKeyRange.bound(options.startDate, options.endDate));
|
|
} else if (options.category) {
|
|
const index = store.index('category');
|
|
request = index.getAll(options.category);
|
|
} else {
|
|
request = store.getAll();
|
|
}
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
let results = request.result as Transaction[];
|
|
if (options.limit) {
|
|
results = results.slice(0, options.limit);
|
|
}
|
|
resolve(results);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Export all data for backup
|
|
*
|
|
* Returns encrypted data that can be imported later.
|
|
*/
|
|
async exportData(): Promise<ArrayBuffer> {
|
|
this.ensureInitialized();
|
|
|
|
const exportData = {
|
|
state: this.wasmLearner!.saveState(),
|
|
transactions: await this.getStoredTransactions(),
|
|
exportedAt: new Date().toISOString(),
|
|
version: 1,
|
|
};
|
|
|
|
return this.encrypt(JSON.stringify(exportData));
|
|
}
|
|
|
|
/**
|
|
* Import data from backup
|
|
*/
|
|
async importData(encryptedData: ArrayBuffer): Promise<void> {
|
|
this.ensureInitialized();
|
|
|
|
const json = await this.decrypt(encryptedData);
|
|
const importData = JSON.parse(json);
|
|
|
|
// Load state
|
|
this.wasmLearner!.loadState(importData.state);
|
|
|
|
// Store transactions
|
|
if (importData.transactions) {
|
|
await this.storeTransactions(importData.transactions);
|
|
}
|
|
|
|
await this.save();
|
|
}
|
|
|
|
/**
|
|
* Ensure learner is initialized
|
|
*/
|
|
private ensureInitialized(): void {
|
|
if (!this.initialized || !this.wasmLearner || !this.db) {
|
|
throw new Error('PlaidLocalLearner not initialized. Call init() first.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close database connection
|
|
*/
|
|
close(): void {
|
|
if (this.db) {
|
|
this.db.close();
|
|
this.db = null;
|
|
}
|
|
this.initialized = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Plaid Link integration helper
|
|
*
|
|
* Handles Plaid Link flow while keeping tokens local.
|
|
*/
|
|
export class PlaidLinkHandler {
|
|
private db: IDBDatabase | null = null;
|
|
|
|
constructor(private config: PlaidConfig) {}
|
|
|
|
/**
|
|
* Initialize handler
|
|
*/
|
|
async init(): Promise<void> {
|
|
this.db = await this.openDatabase();
|
|
}
|
|
|
|
private openDatabase(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Store access token locally
|
|
*
|
|
* Token never leaves the browser.
|
|
*/
|
|
async storeToken(itemId: string, accessToken: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite');
|
|
const store = transaction.objectStore(STORES.TOKENS);
|
|
|
|
// Store encrypted (in production, use proper encryption)
|
|
const request = store.put(
|
|
{
|
|
accessToken,
|
|
storedAt: Date.now(),
|
|
},
|
|
itemId
|
|
);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get stored token
|
|
*/
|
|
async getToken(itemId: string): Promise<string | null> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TOKENS], 'readonly');
|
|
const store = transaction.objectStore(STORES.TOKENS);
|
|
const request = store.get(itemId);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
resolve(request.result?.accessToken ?? null);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete token
|
|
*/
|
|
async deleteToken(itemId: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite');
|
|
const store = transaction.objectStore(STORES.TOKENS);
|
|
const request = store.delete(itemId);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List all stored item IDs
|
|
*/
|
|
async listItems(): Promise<string[]> {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([STORES.TOKENS], 'readonly');
|
|
const store = transaction.objectStore(STORES.TOKENS);
|
|
const request = store.getAllKeys();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result as string[]);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Export default instance
|
|
export default PlaidLocalLearner;
|