Skip to content

Commit

Permalink
idb adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
mmalmi committed Feb 22, 2025
1 parent b2b7134 commit d837901
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 2,744 deletions.
2 changes: 1 addition & 1 deletion irisdb-hooks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "irisdb-hooks",
"version": "1.0.13",
"version": "1.0.14",
"type": "module",
"description": "React hooks for IrisDB",
"main": "dist/irisdb-hooks.umd.js",
Expand Down
17 changes: 0 additions & 17 deletions irisdb-nostr/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,6 @@ export type NostrSubscribe = (filter: NostrFilter, onEvent: (e: NostrEvent) => v

export type NostrPublish = (event: Partial<NostrEvent>) => void;

/**
* Callback function for handling decrypted messages
* @param message - The decrypted message object
*/
export type MessageCallback = (message: Message) => void;

export const EVENT_KIND = 4;
export const MAX_SKIP = 100;

export type NostrEvent = {
id: string;
pubkey: string;
Expand All @@ -94,11 +85,3 @@ export type NostrEvent = {
content: string;
sig: string;
};

export enum Sender {
Us,
Them,
}

export type EncryptFunction = (plaintext: string, pubkey: string) => Promise<string>;
export type DecryptFunction = (ciphertext: string, pubkey: string) => Promise<string>;
7 changes: 5 additions & 2 deletions irisdb/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "irisdb",
"version": "1.0.12",
"version": "1.0.13",
"type": "module",
"description": "Treelike subscribable data structure that can be easily synced over different transports",
"main": "dist/irisdb.umd.js",
Expand All @@ -24,5 +24,8 @@
"files": [
"src",
"dist"
]
],
"devDependencies": {
"fake-indexeddb": "^6.0.0"
}
}
84 changes: 84 additions & 0 deletions irisdb/src/adapters/IndexedDBAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'fake-indexeddb/auto'; // Automatically sets up fake IndexedDB globally

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { Callback, Unsubscribe } from '../types.ts';
import { IndexedDBAdapter } from './IndexedDBAdapter.ts';

describe('IndexedDBAdapter with fake-indexeddb', () => {
let adapter: IndexedDBAdapter;

beforeEach(async () => {
vi.clearAllMocks();
adapter = new IndexedDBAdapter('testDB', 'testStore');
await adapter['dbReady']; // Wait for DB initialization
});

describe('get()', () => {
it('should retrieve the stored value for a given path', async () => {
const mockCallback: Callback = vi.fn();
const mockValue = { value: 'testValue', updatedAt: Date.now() };

await adapter.set('somePath', mockValue);
const unsubscribe: Unsubscribe = adapter.get('somePath', mockCallback);

await new Promise((resolve) => setTimeout(resolve, 10));

expect(mockCallback).toHaveBeenCalledWith(
'testValue',
'somePath',
mockValue.updatedAt,
expect.any(Function),
);
unsubscribe();
});
});

describe('set()', () => {
it('should set the value at the given path', async () => {
const mockCallback: Callback = vi.fn();
const mockValue = { value: 'newValue', updatedAt: Date.now() };

await adapter.set('anotherPath', mockValue);
adapter.get('anotherPath', mockCallback);

await new Promise((resolve) => setTimeout(resolve, 10));

expect(mockCallback).toHaveBeenCalledWith(
'newValue',
'anotherPath',
mockValue.updatedAt,
expect.any(Function),
);
});
});

describe('list()', () => {
it('should list child nodes under the given path', async () => {
const mockCallback: Callback = vi.fn();
const mockValue1 = { value: 'childValue1', updatedAt: Date.now() };
const mockValue2 = { value: 'childValue2', updatedAt: Date.now() };

await adapter.set('parent/child1', mockValue1);
await adapter.set('parent/child2', mockValue2);
const unsubscribe: Unsubscribe = adapter.list('parent', mockCallback);

await new Promise((resolve) => setTimeout(resolve, 10));

expect(mockCallback).toHaveBeenCalledWith(
'childValue1',
'parent/child1',
mockValue1.updatedAt,
expect.any(Function),
);
expect(mockCallback).toHaveBeenCalledWith(
'childValue2',
'parent/child2',
mockValue2.updatedAt,
expect.any(Function),
);

unsubscribe();
});
});
});
222 changes: 222 additions & 0 deletions irisdb/src/adapters/IndexedDBAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { Adapter, Callback, NodeValue, Unsubscribe } from '../types';

/**
* IndexedDB adapter that works in both main thread and service worker contexts
*/
export class IndexedDBAdapter implements Adapter {
private dbName: string;
private storeName: string;
private db: IDBDatabase | null = null;
private dbReady: Promise<void>;
private callbacks = new Map<string, Set<Callback>>();
private idbFactory: IDBFactory;

constructor(dbName = 'irisdb', storeName = 'keyval') {
this.dbName = dbName;
this.storeName = storeName;
// Use the appropriate IDBFactory depending on context
this.idbFactory = typeof window !== 'undefined' ? window.indexedDB : self.indexedDB;
this.dbReady = this.initDB();
}

private async initDB(): Promise<void> {
return new Promise((resolve, reject) => {
const request = this.idbFactory.open(this.dbName, 1);

request.onerror = () => {
console.error('IndexedDB error:', request.error);
reject(request.error);
};

request.onblocked = () => {
console.warn('IndexedDB blocked. Please close other tabs/windows.');
};

request.onsuccess = () => {
this.db = request.result;

// Handle connection errors
this.db.onerror = (event) => {
console.error('IndexedDB error:', (event as ErrorEvent).error);
};

// Handle version change (e.g., another tab/worker upgrades the DB)
this.db.onversionchange = () => {
this.db?.close();
this.db = null;
this.dbReady = this.initDB();
};

resolve();
};

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
// Create store with a compound index for path-based queries
const store = db.createObjectStore(this.storeName, { keyPath: 'path' });
store.createIndex('pathIndex', 'path', { unique: true });
store.createIndex('updatedAtIndex', 'updatedAt');
}
};
});
}

private async getStore(mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
if (!this.db) {
await this.dbReady;
}
if (!this.db) {
throw new Error('Database not initialized');
}
const transaction = this.db.transaction(this.storeName, mode);
return transaction.objectStore(this.storeName);
}

private addCallback(path: string, callback: Callback) {
if (!this.callbacks.has(path)) {
this.callbacks.set(path, new Set());
}
this.callbacks.get(path)!.add(callback);
}

private removeCallback(path: string, callback: Callback) {
const callbacks = this.callbacks.get(path);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.callbacks.delete(path);
}
}
}

get(path: string, callback: Callback): Unsubscribe {
this.getStore()
.then((store) => {
const request = store.get(path);

request.onsuccess = () => {
const record = request.result;
if (record) {
const { value, updatedAt } = record;
callback(value, path, updatedAt, () => this.removeCallback(path, callback));
} else {
callback(undefined, path, undefined, () => this.removeCallback(path, callback));
}
};

request.onerror = () => {
console.error('Error reading from IndexedDB:', request.error);
callback(undefined, path, undefined, () => this.removeCallback(path, callback));
};
})
.catch((error) => {
console.error('IndexedDB get error:', error);
callback(undefined, path, undefined, () => {});
});

this.addCallback(path, callback);
return () => this.removeCallback(path, callback);
}

async set(path: string, value: NodeValue): Promise<void> {
if (value.updatedAt === undefined) {
throw new Error(`Invalid value: ${JSON.stringify(value)}`);
}

try {
const store = await this.getStore('readwrite');
const record = {
path,
value: value.value,
updatedAt: value.updatedAt,
expiresAt: value.expiresAt,
};

return new Promise((resolve, reject) => {
const request = store.put(record);

Check failure on line 137 in irisdb/src/adapters/IndexedDBAdapter.ts

View workflow job for this annotation

GitHub Actions / Run npm Tests

src/adapters/IndexedDBAdapter.test.ts > IndexedDBAdapter with fake-indexeddb > get() > should retrieve the stored value for a given path

ReferenceError: structuredClone is not defined ❯ buildRecordAddPut ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:33:17 ❯ FDBObjectStore.put ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:120:20 ❯ src/adapters/IndexedDBAdapter.ts:137:31 ❯ IndexedDBAdapter.set src/adapters/IndexedDBAdapter.ts:136:14 ❯ src/adapters/IndexedDBAdapter.test.ts:22:7

Check failure on line 137 in irisdb/src/adapters/IndexedDBAdapter.ts

View workflow job for this annotation

GitHub Actions / Run npm Tests

src/adapters/IndexedDBAdapter.test.ts > IndexedDBAdapter with fake-indexeddb > set() > should set the value at the given path

ReferenceError: structuredClone is not defined ❯ buildRecordAddPut ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:33:17 ❯ FDBObjectStore.put ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:120:20 ❯ src/adapters/IndexedDBAdapter.ts:137:31 ❯ IndexedDBAdapter.set src/adapters/IndexedDBAdapter.ts:136:14 ❯ src/adapters/IndexedDBAdapter.test.ts:42:7

Check failure on line 137 in irisdb/src/adapters/IndexedDBAdapter.ts

View workflow job for this annotation

GitHub Actions / Run npm Tests

src/adapters/IndexedDBAdapter.test.ts > IndexedDBAdapter with fake-indexeddb > list() > should list child nodes under the given path

ReferenceError: structuredClone is not defined ❯ buildRecordAddPut ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:33:17 ❯ FDBObjectStore.put ../node_modules/fake-indexeddb/build/esm/FDBObjectStore.js:120:20 ❯ src/adapters/IndexedDBAdapter.ts:137:31 ❯ IndexedDBAdapter.set src/adapters/IndexedDBAdapter.ts:136:14 ❯ src/adapters/IndexedDBAdapter.test.ts:62:7

request.onsuccess = () => {
const callbacks = this.callbacks.get(path);
if (callbacks) {
callbacks.forEach((callback) => {
callback(value.value, path, value.updatedAt, () =>
this.removeCallback(path, callback),
);
});
}
resolve();
};

request.onerror = () => {
console.error('Error writing to IndexedDB:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('IndexedDB set error:', error);
throw error;
}
}

list(path: string, callback: Callback): Unsubscribe {
this.getStore()
.then((store) => {
const index = store.index('pathIndex');
const range = IDBKeyRange.bound(`${path}/`, `${path}/\uffff`, false, false);

const request = index.openCursor(range);

request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
const record = cursor.value;
const storedPath = record.path;
const remainingPath = storedPath.replace(`${path}/`, '');

if (!remainingPath.includes('/')) {
callback(record.value, storedPath, record.updatedAt, () =>
this.removeCallback(path, callback),
);
}
cursor.continue();
}
};

request.onerror = () => {
console.error('Error listing from IndexedDB:', request.error);
};
})
.catch((error) => {
console.error('IndexedDB list error:', error);
});

this.addCallback(path, callback);
return () => this.removeCallback(path, callback);
}

/**
* Delete expired entries. Can be called periodically for cleanup.
*/
async cleanup(): Promise<void> {
try {
const store = await this.getStore('readwrite');
const now = Date.now();
const index = store.index('updatedAtIndex');
const request = index.openCursor();

request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
const record = cursor.value;
if (record.expiresAt && record.expiresAt < now) {
cursor.delete();
}
cursor.continue();
}
};
} catch (error) {
console.error('IndexedDB cleanup error:', error);
}
}
}
Loading

0 comments on commit d837901

Please sign in to comment.