Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 34 additions & 51 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,21 @@ export interface StorageQuota {

class Storage {
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;

/**
* Initialize IndexedDB
* Initialize IndexedDB (idempotent — safe to call multiple times)
*/
async init(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.db) return;
if (this.initPromise) return this.initPromise;

this.initPromise = new Promise<void>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onerror = () => {
logger.error('IndexedDB initialization failed');
this.initPromise = null;
reject(request.error);
};

Expand Down Expand Up @@ -92,6 +97,17 @@ class Storage {
logger.info('Database upgraded to version ' + DB_VERSION);
};
});

return this.initPromise;
}

/**
* Ensure database is initialized before performing operations
*/
private async ensureInit(): Promise<void> {
if (!this.db) {
await this.init();
}
}

/**
Expand All @@ -101,13 +117,9 @@ class Storage {
storeName: K,
value: StorageObjects[K],
): Promise<IDBValidKey> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readwrite');
const transaction = this.db!.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(value);

Expand All @@ -123,13 +135,9 @@ class Storage {
storeName: K,
key: IDBValidKey,
): Promise<StorageObjects[K] | undefined> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readonly');
const transaction = this.db!.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);

Expand All @@ -144,13 +152,9 @@ class Storage {
async getAll<K extends keyof StorageObjects>(
storeName: K,
): Promise<StorageObjects[K][]> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readonly');
const transaction = this.db!.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();

Expand All @@ -166,13 +170,9 @@ class Storage {
storeName: K,
key: IDBValidKey,
): Promise<void> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readwrite');
const transaction = this.db!.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(key);

Expand All @@ -185,13 +185,9 @@ class Storage {
* Clear all objects from a store
*/
async clear<K extends keyof StorageObjects>(storeName: K): Promise<void> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readwrite');
const transaction = this.db!.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();

Expand All @@ -200,8 +196,6 @@ class Storage {
});
}

/**

/**
* Bulk write operations for OPML import
* More efficient than multiple put() calls
Expand All @@ -210,13 +204,11 @@ class Storage {
storeName: K,
values: StorageObjects[K][],
): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}
await this.ensureInit();
if (values.length === 0) return;

const transaction = this.db.transaction([storeName], 'readwrite');
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);

let completed = 0;
Expand Down Expand Up @@ -244,11 +236,6 @@ class Storage {
});

transaction.onerror = () => reject(transaction.error);

// Handle empty array
if (values.length === 0) {
resolve();
}
});
}

Expand Down Expand Up @@ -306,13 +293,9 @@ class Storage {
indexName: string,
value: IDBValidKey,
): Promise<StorageObjects[K][]> {
await this.ensureInit();
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'));
return;
}

const transaction = this.db.transaction([storeName], 'readonly');
const transaction = this.db!.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value);
Expand Down
56 changes: 56 additions & 0 deletions tests/integration/feedServiceInit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Integration test: Feed Service with auto-initialization
* Ensures subscribeFeed and getArticlesForFeed work without explicit storage.init()
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { storage } from '@lib/storage';
import { subscribeFeed, getArticlesForFeed } from '@services/feedService';
import { server } from '../setup';
import { http, HttpResponse } from 'msw';
import { RSS2_SAMPLE } from '../fixtures/feeds';

describe('Feed Service auto-initialization', () => {
beforeEach(async () => {
// Only clear data, relying on auto-init inside storage methods
await storage.clear('feeds');
await storage.clear('articles');
});

it('should subscribe to a feed without explicit storage.init()', async () => {
server.use(
http.get('https://example.com/feed.xml', () => {
return HttpResponse.text(RSS2_SAMPLE, {
headers: { 'Content-Type': 'application/rss+xml' },
});
})
);

const result = await subscribeFeed('https://example.com/feed.xml');

expect(result.success).toBe(true);
expect(result.feed).toBeDefined();
expect(result.feed?.title).toBe('Sample RSS Feed');

// Verify persistence
const feeds = await storage.getAll('feeds');
expect(feeds).toHaveLength(1);
});

it('should load articles for a feed without explicit storage.init()', async () => {
server.use(
http.get('https://example.com/feed.xml', () => {
return HttpResponse.text(RSS2_SAMPLE, {
headers: { 'Content-Type': 'application/rss+xml' },
});
})
);

const result = await subscribeFeed('https://example.com/feed.xml');
expect(result.success).toBe(true);

const articles = await getArticlesForFeed(result.feed!.id);
expect(articles.length).toBeGreaterThan(0);
expect(articles[0].feedId).toBe(result.feed!.id);
});
});
65 changes: 64 additions & 1 deletion tests/unit/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Unit tests for Storage bulk operations
* Unit tests for Storage operations and auto-initialization
*/

import { describe, it, expect, beforeEach } from 'vitest';
Expand All @@ -14,6 +14,69 @@ describe('Storage', () => {
await storage.clear('articles');
});

describe('auto-initialization', () => {
it('should auto-initialize when calling put without explicit init', async () => {
// Create a fresh Storage instance to test auto-init
const { storage: freshStorage } = await import('@lib/storage');
const feed: Feed = {
id: 'auto-init-feed',
url: 'https://example.com/feed.xml',
title: 'Auto Init Feed',
description: 'Test',
lastFetchedAt: null,
refreshIntervalMinutes: 60,
paused: false,
errorCount: 0,
createdAt: new Date(),
deletedAt: null,
};

// Should NOT throw "Database not initialized"
await freshStorage.put('feeds', feed);
const stored = await freshStorage.get('feeds', 'auto-init-feed');
expect(stored).toBeDefined();
expect(stored?.title).toBe('Auto Init Feed');
});

it('should auto-initialize when calling getAll without explicit init', async () => {
const { storage: freshStorage } = await import('@lib/storage');
// Should NOT throw "Database not initialized"
const feeds = await freshStorage.getAll('feeds');
expect(Array.isArray(feeds)).toBe(true);
});

it('should auto-initialize when calling get without explicit init', async () => {
const { storage: freshStorage } = await import('@lib/storage');
// Should NOT throw "Database not initialized"
const result = await freshStorage.get('feeds', 'non-existent');
expect(result).toBeUndefined();
});

it('should handle multiple concurrent init calls safely', async () => {
const { storage: freshStorage } = await import('@lib/storage');
// Call init multiple times concurrently
await expect(
Promise.all([
freshStorage.init(),
freshStorage.init(),
freshStorage.init(),
])
).resolves.not.toThrow();
});

it('should handle concurrent storage operations without explicit init', async () => {
const { storage: freshStorage } = await import('@lib/storage');
// Multiple operations in parallel should all auto-init safely
const results = await Promise.all([
freshStorage.getAll('feeds'),
freshStorage.getAll('articles'),
freshStorage.getAll('categories'),
]);
expect(results).toHaveLength(3);
results.forEach(r => expect(Array.isArray(r)).toBe(true));
});
});

describe('bulkPut', () => {
it('should insert multiple items efficiently', async () => {
const feeds: Feed[] = Array.from({ length: 10 }, (_, i) => ({
Expand Down
Loading