Skip to content

Commit eb9d369

Browse files
Copilotchiga0
andauthored
fix: make IndexedDB storage self-initializing to prevent startup failures (#27)
* Initial plan * fix: make Storage auto-initialize to prevent "Database not initialized" errors - Make storage.init() idempotent with cached init promise - Add ensureInit() called by all storage methods before DB operations - Fix dangling JSDoc comment in storage.ts - Add unit tests for auto-initialization and concurrent init safety - Add integration test for feed subscription without explicit init Co-authored-by: chiga0 <24784430+chiga0@users.noreply.github.com> * fix: address code review feedback - move empty array check after ensureInit, improve test assertions Co-authored-by: chiga0 <24784430+chiga0@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: chiga0 <24784430+chiga0@users.noreply.github.com>
1 parent 37859a5 commit eb9d369

3 files changed

Lines changed: 154 additions & 52 deletions

File tree

src/lib/storage.ts

Lines changed: 34 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,21 @@ export interface StorageQuota {
2828

2929
class Storage {
3030
private db: IDBDatabase | null = null;
31+
private initPromise: Promise<void> | null = null;
3132

3233
/**
33-
* Initialize IndexedDB
34+
* Initialize IndexedDB (idempotent — safe to call multiple times)
3435
*/
3536
async init(): Promise<void> {
36-
return new Promise((resolve, reject) => {
37+
if (this.db) return;
38+
if (this.initPromise) return this.initPromise;
39+
40+
this.initPromise = new Promise<void>((resolve, reject) => {
3741
const request = indexedDB.open(DB_NAME, DB_VERSION);
3842

3943
request.onerror = () => {
4044
logger.error('IndexedDB initialization failed');
45+
this.initPromise = null;
4146
reject(request.error);
4247
};
4348

@@ -92,6 +97,17 @@ class Storage {
9297
logger.info('Database upgraded to version ' + DB_VERSION);
9398
};
9499
});
100+
101+
return this.initPromise;
102+
}
103+
104+
/**
105+
* Ensure database is initialized before performing operations
106+
*/
107+
private async ensureInit(): Promise<void> {
108+
if (!this.db) {
109+
await this.init();
110+
}
95111
}
96112

97113
/**
@@ -101,13 +117,9 @@ class Storage {
101117
storeName: K,
102118
value: StorageObjects[K],
103119
): Promise<IDBValidKey> {
120+
await this.ensureInit();
104121
return new Promise((resolve, reject) => {
105-
if (!this.db) {
106-
reject(new Error('Database not initialized'));
107-
return;
108-
}
109-
110-
const transaction = this.db.transaction([storeName], 'readwrite');
122+
const transaction = this.db!.transaction([storeName], 'readwrite');
111123
const store = transaction.objectStore(storeName);
112124
const request = store.put(value);
113125

@@ -123,13 +135,9 @@ class Storage {
123135
storeName: K,
124136
key: IDBValidKey,
125137
): Promise<StorageObjects[K] | undefined> {
138+
await this.ensureInit();
126139
return new Promise((resolve, reject) => {
127-
if (!this.db) {
128-
reject(new Error('Database not initialized'));
129-
return;
130-
}
131-
132-
const transaction = this.db.transaction([storeName], 'readonly');
140+
const transaction = this.db!.transaction([storeName], 'readonly');
133141
const store = transaction.objectStore(storeName);
134142
const request = store.get(key);
135143

@@ -144,13 +152,9 @@ class Storage {
144152
async getAll<K extends keyof StorageObjects>(
145153
storeName: K,
146154
): Promise<StorageObjects[K][]> {
155+
await this.ensureInit();
147156
return new Promise((resolve, reject) => {
148-
if (!this.db) {
149-
reject(new Error('Database not initialized'));
150-
return;
151-
}
152-
153-
const transaction = this.db.transaction([storeName], 'readonly');
157+
const transaction = this.db!.transaction([storeName], 'readonly');
154158
const store = transaction.objectStore(storeName);
155159
const request = store.getAll();
156160

@@ -166,13 +170,9 @@ class Storage {
166170
storeName: K,
167171
key: IDBValidKey,
168172
): Promise<void> {
173+
await this.ensureInit();
169174
return new Promise((resolve, reject) => {
170-
if (!this.db) {
171-
reject(new Error('Database not initialized'));
172-
return;
173-
}
174-
175-
const transaction = this.db.transaction([storeName], 'readwrite');
175+
const transaction = this.db!.transaction([storeName], 'readwrite');
176176
const store = transaction.objectStore(storeName);
177177
const request = store.delete(key);
178178

@@ -185,13 +185,9 @@ class Storage {
185185
* Clear all objects from a store
186186
*/
187187
async clear<K extends keyof StorageObjects>(storeName: K): Promise<void> {
188+
await this.ensureInit();
188189
return new Promise((resolve, reject) => {
189-
if (!this.db) {
190-
reject(new Error('Database not initialized'));
191-
return;
192-
}
193-
194-
const transaction = this.db.transaction([storeName], 'readwrite');
190+
const transaction = this.db!.transaction([storeName], 'readwrite');
195191
const store = transaction.objectStore(storeName);
196192
const request = store.clear();
197193

@@ -200,8 +196,6 @@ class Storage {
200196
});
201197
}
202198

203-
/**
204-
205199
/**
206200
* Bulk write operations for OPML import
207201
* More efficient than multiple put() calls
@@ -210,13 +204,11 @@ class Storage {
210204
storeName: K,
211205
values: StorageObjects[K][],
212206
): Promise<void> {
213-
return new Promise((resolve, reject) => {
214-
if (!this.db) {
215-
reject(new Error('Database not initialized'));
216-
return;
217-
}
207+
await this.ensureInit();
208+
if (values.length === 0) return;
218209

219-
const transaction = this.db.transaction([storeName], 'readwrite');
210+
return new Promise((resolve, reject) => {
211+
const transaction = this.db!.transaction([storeName], 'readwrite');
220212
const store = transaction.objectStore(storeName);
221213

222214
let completed = 0;
@@ -244,11 +236,6 @@ class Storage {
244236
});
245237

246238
transaction.onerror = () => reject(transaction.error);
247-
248-
// Handle empty array
249-
if (values.length === 0) {
250-
resolve();
251-
}
252239
});
253240
}
254241

@@ -306,13 +293,9 @@ class Storage {
306293
indexName: string,
307294
value: IDBValidKey,
308295
): Promise<StorageObjects[K][]> {
296+
await this.ensureInit();
309297
return new Promise((resolve, reject) => {
310-
if (!this.db) {
311-
reject(new Error('Database not initialized'));
312-
return;
313-
}
314-
315-
const transaction = this.db.transaction([storeName], 'readonly');
298+
const transaction = this.db!.transaction([storeName], 'readonly');
316299
const store = transaction.objectStore(storeName);
317300
const index = store.index(indexName);
318301
const request = index.getAll(value);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Integration test: Feed Service with auto-initialization
3+
* Ensures subscribeFeed and getArticlesForFeed work without explicit storage.init()
4+
*/
5+
6+
import { describe, it, expect, beforeEach } from 'vitest';
7+
import { storage } from '@lib/storage';
8+
import { subscribeFeed, getArticlesForFeed } from '@services/feedService';
9+
import { server } from '../setup';
10+
import { http, HttpResponse } from 'msw';
11+
import { RSS2_SAMPLE } from '../fixtures/feeds';
12+
13+
describe('Feed Service auto-initialization', () => {
14+
beforeEach(async () => {
15+
// Only clear data, relying on auto-init inside storage methods
16+
await storage.clear('feeds');
17+
await storage.clear('articles');
18+
});
19+
20+
it('should subscribe to a feed without explicit storage.init()', async () => {
21+
server.use(
22+
http.get('https://example.com/feed.xml', () => {
23+
return HttpResponse.text(RSS2_SAMPLE, {
24+
headers: { 'Content-Type': 'application/rss+xml' },
25+
});
26+
})
27+
);
28+
29+
const result = await subscribeFeed('https://example.com/feed.xml');
30+
31+
expect(result.success).toBe(true);
32+
expect(result.feed).toBeDefined();
33+
expect(result.feed?.title).toBe('Sample RSS Feed');
34+
35+
// Verify persistence
36+
const feeds = await storage.getAll('feeds');
37+
expect(feeds).toHaveLength(1);
38+
});
39+
40+
it('should load articles for a feed without explicit storage.init()', async () => {
41+
server.use(
42+
http.get('https://example.com/feed.xml', () => {
43+
return HttpResponse.text(RSS2_SAMPLE, {
44+
headers: { 'Content-Type': 'application/rss+xml' },
45+
});
46+
})
47+
);
48+
49+
const result = await subscribeFeed('https://example.com/feed.xml');
50+
expect(result.success).toBe(true);
51+
52+
const articles = await getArticlesForFeed(result.feed!.id);
53+
expect(articles.length).toBeGreaterThan(0);
54+
expect(articles[0].feedId).toBe(result.feed!.id);
55+
});
56+
});

tests/unit/storage.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Unit tests for Storage bulk operations
2+
* Unit tests for Storage operations and auto-initialization
33
*/
44

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

17+
describe('auto-initialization', () => {
18+
it('should auto-initialize when calling put without explicit init', async () => {
19+
// Create a fresh Storage instance to test auto-init
20+
const { storage: freshStorage } = await import('@lib/storage');
21+
const feed: Feed = {
22+
id: 'auto-init-feed',
23+
url: 'https://example.com/feed.xml',
24+
title: 'Auto Init Feed',
25+
description: 'Test',
26+
lastFetchedAt: null,
27+
refreshIntervalMinutes: 60,
28+
paused: false,
29+
errorCount: 0,
30+
createdAt: new Date(),
31+
deletedAt: null,
32+
};
33+
34+
// Should NOT throw "Database not initialized"
35+
await freshStorage.put('feeds', feed);
36+
const stored = await freshStorage.get('feeds', 'auto-init-feed');
37+
expect(stored).toBeDefined();
38+
expect(stored?.title).toBe('Auto Init Feed');
39+
});
40+
41+
it('should auto-initialize when calling getAll without explicit init', async () => {
42+
const { storage: freshStorage } = await import('@lib/storage');
43+
// Should NOT throw "Database not initialized"
44+
const feeds = await freshStorage.getAll('feeds');
45+
expect(Array.isArray(feeds)).toBe(true);
46+
});
47+
48+
it('should auto-initialize when calling get without explicit init', async () => {
49+
const { storage: freshStorage } = await import('@lib/storage');
50+
// Should NOT throw "Database not initialized"
51+
const result = await freshStorage.get('feeds', 'non-existent');
52+
expect(result).toBeUndefined();
53+
});
54+
55+
it('should handle multiple concurrent init calls safely', async () => {
56+
const { storage: freshStorage } = await import('@lib/storage');
57+
// Call init multiple times concurrently
58+
await expect(
59+
Promise.all([
60+
freshStorage.init(),
61+
freshStorage.init(),
62+
freshStorage.init(),
63+
])
64+
).resolves.not.toThrow();
65+
});
66+
67+
it('should handle concurrent storage operations without explicit init', async () => {
68+
const { storage: freshStorage } = await import('@lib/storage');
69+
// Multiple operations in parallel should all auto-init safely
70+
const results = await Promise.all([
71+
freshStorage.getAll('feeds'),
72+
freshStorage.getAll('articles'),
73+
freshStorage.getAll('categories'),
74+
]);
75+
expect(results).toHaveLength(3);
76+
results.forEach(r => expect(Array.isArray(r)).toBe(true));
77+
});
78+
});
79+
1780
describe('bulkPut', () => {
1881
it('should insert multiple items efficiently', async () => {
1982
const feeds: Feed[] = Array.from({ length: 10 }, (_, i) => ({

0 commit comments

Comments
 (0)