Skip to content
/ beebuzz Public

Commit 0f61e5d

Browse files
authored
fix(hive): resolve push handling regressions and improve notification UX (#20)
- Make Service Worker resilient to IndexedDB upgrade blocks during push by creating the DB on-demand when it does not exist yet. - Harden notification click flow: best-effort focus/openWindow, catch malformed client URLs, and persist after opening to avoid Android activation stalls. - Remove redundant E2E envelope null check after type guard. - Improve relative time formatting: show seconds for recent messages, absolute HH:MM for same-day >1h and previous days. - Expand test coverage for SW push, click, credentials, and bootstrap.
1 parent a8ba4aa commit 0f61e5d

10 files changed

Lines changed: 655 additions & 50 deletions

File tree

web/apps/hive/src/lib/services/app-bootstrap.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,53 @@ describe('bootstrapAppShell', () => {
184184
'postChecks'
185185
]);
186186
});
187+
188+
it('propagates legacy notification migration failures before draining IndexedDB', async () => {
189+
const calls: string[] = [];
190+
const failure = new Error('migration failed');
191+
192+
await expect(
193+
bootstrapAppShell({
194+
registerServiceWorker: () => {
195+
calls.push('register');
196+
return Promise.resolve({ scope: '/' });
197+
},
198+
checkPaired: () => {
199+
calls.push('checkPaired');
200+
return Promise.resolve(true);
201+
},
202+
getDeviceId: () => {
203+
calls.push('getDeviceId');
204+
return Promise.resolve('dev-a');
205+
},
206+
activateNotifications: (deviceId) => {
207+
calls.push(`activate:${deviceId}`);
208+
},
209+
attachServiceWorkerListeners: () => {
210+
calls.push('attach');
211+
},
212+
migrateLegacyNotifications: (deviceId) => {
213+
calls.push(`migrate:${deviceId}`);
214+
return Promise.reject(failure);
215+
},
216+
loadPersistedNotifications: (phase) => {
217+
calls.push(`load:${phase}`);
218+
return Promise.resolve();
219+
},
220+
runPostPairingChecks: () => {
221+
calls.push('postChecks');
222+
return Promise.resolve();
223+
}
224+
})
225+
).rejects.toBe(failure);
226+
227+
expect(calls).toEqual([
228+
'register',
229+
'checkPaired',
230+
'getDeviceId',
231+
'activate:dev-a',
232+
'attach',
233+
'migrate:dev-a'
234+
]);
235+
});
187236
});

web/apps/hive/src/lib/services/device-keys-repository.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ENCRYPTED_KEY_STORE,
33
ENCRYPTION_METADATA_STORE,
44
WRAPPING_KEY_STORE,
5+
openExistingHiveDB,
56
openHiveDB
67
} from './hive-db';
78

@@ -76,7 +77,7 @@ function putWrappedIdentity(
7677
export const deviceKeysRepository = {
7778
/** Returns the first usable key metadata record, skipping reserved entries. */
7879
async getFirstMetadata(): Promise<KeyMetadata | null> {
79-
const db = await openHiveDB();
80+
const db = await openExistingHiveDB();
8081

8182
return new Promise((resolve, reject) => {
8283
const tx = db.transaction(ENCRYPTION_METADATA_STORE, 'readonly');
@@ -104,7 +105,7 @@ export const deviceKeysRepository = {
104105
wrappingKey: CryptoKey | null;
105106
wrappedPrivateKey: WrappedPrivateKeyRecord | null;
106107
}> {
107-
const db = await openHiveDB();
108+
const db = await openExistingHiveDB();
108109
const [wrappingKey, wrappedPrivateKey] = await Promise.all([
109110
getStoreValue<CryptoKey>(db, WRAPPING_KEY_STORE, keyId, 'Wrapping key fetch failed'),
110111
getStoreValue<WrappedPrivateKeyRecord>(
@@ -194,7 +195,7 @@ export const deviceKeysRepository = {
194195

195196
/** Retrieves stored device credentials, if available. */
196197
async getDeviceCredentials(): Promise<DeviceCredentials | null> {
197-
const db = await openHiveDB();
198+
const db = await openExistingHiveDB();
198199
return getStoreValue<DeviceCredentials>(
199200
db,
200201
ENCRYPTION_METADATA_STORE,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import {
3+
ENCRYPTED_KEY_STORE,
4+
ENCRYPTION_METADATA_STORE,
5+
HIVE_DB_NAME,
6+
NOTIFICATIONS_BY_DEVICE_INDEX,
7+
NOTIFICATIONS_STORE,
8+
WRAPPING_KEY_STORE,
9+
openExistingHiveDB,
10+
openHiveDB
11+
} from './hive-db';
12+
13+
function deleteHiveDB(): Promise<void> {
14+
return new Promise((resolve, reject) => {
15+
const request = indexedDB.deleteDatabase(HIVE_DB_NAME);
16+
request.onsuccess = () => resolve();
17+
request.onerror = () => reject(new Error(request.error?.message ?? 'Delete failed'));
18+
request.onblocked = () => reject(new Error('Delete blocked'));
19+
});
20+
}
21+
22+
function createV1HiveDB(): Promise<IDBDatabase> {
23+
return new Promise((resolve, reject) => {
24+
const request = indexedDB.open(HIVE_DB_NAME, 1);
25+
request.onupgradeneeded = () => {
26+
const db = request.result;
27+
db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' });
28+
db.createObjectStore(ENCRYPTION_METADATA_STORE, { keyPath: 'id' });
29+
db.createObjectStore(WRAPPING_KEY_STORE);
30+
db.createObjectStore(ENCRYPTED_KEY_STORE, { keyPath: 'id' });
31+
};
32+
request.onsuccess = () => resolve(request.result);
33+
request.onerror = () => reject(new Error(request.error?.message ?? 'Open failed'));
34+
});
35+
}
36+
37+
describe('hive-db', () => {
38+
beforeEach(async () => {
39+
await deleteHiveDB();
40+
});
41+
42+
it('opens an existing v1 database without forcing the v2 upgrade', async () => {
43+
const v1DB = await createV1HiveDB();
44+
v1DB.close();
45+
46+
const existing = await openExistingHiveDB();
47+
48+
expect(existing.version).toBe(1);
49+
expect(existing.objectStoreNames.contains(ENCRYPTION_METADATA_STORE)).toBe(true);
50+
existing.close();
51+
});
52+
53+
it('creates an empty v1 database when no Hive database exists yet', async () => {
54+
const existing = await openExistingHiveDB();
55+
56+
expect(existing.version).toBe(1);
57+
expect(existing.objectStoreNames.length).toBe(0);
58+
existing.close();
59+
});
60+
61+
it('creates missing stores when upgrading a sparse existing database to v2', async () => {
62+
const sparseDB = await openExistingHiveDB();
63+
expect(sparseDB.version).toBe(1);
64+
sparseDB.close();
65+
66+
const upgraded = await openHiveDB();
67+
68+
expect(upgraded.version).toBe(2);
69+
expect(upgraded.objectStoreNames.contains(NOTIFICATIONS_STORE)).toBe(true);
70+
const tx = upgraded.transaction(NOTIFICATIONS_STORE, 'readonly');
71+
expect(
72+
tx.objectStore(NOTIFICATIONS_STORE).indexNames.contains(NOTIFICATIONS_BY_DEVICE_INDEX)
73+
).toBe(true);
74+
upgraded.close();
75+
});
76+
});

web/apps/hive/src/lib/services/hive-db.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@ export const ENCRYPTION_METADATA_STORE = 'encryption_keys';
66
export const WRAPPING_KEY_STORE = 'wrapping_keys';
77
export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys';
88

9-
/** Opens the shared Hive IndexedDB database and creates required stores. */
9+
function attachVersionChangeClose(db: IDBDatabase): IDBDatabase {
10+
// If another context (page or service worker) needs to upgrade, close
11+
// this connection so the upgrade is not blocked.
12+
db.onversionchange = () => {
13+
db.close();
14+
};
15+
return db;
16+
}
17+
18+
/** Opens the shared Hive IndexedDB database and creates or upgrades required stores. */
1019
export function openHiveDB(): Promise<IDBDatabase> {
1120
return new Promise((resolve, reject) => {
1221
const request = indexedDB.open(HIVE_DB_NAME, HIVE_DB_VERSION);
@@ -23,7 +32,10 @@ export function openHiveDB(): Promise<IDBDatabase> {
2332
// v1 -> v2: per-device scoping was introduced. Add the by-device index to
2433
// the existing store so legacy rows can be migrated lazily by the app shell.
2534
if (oldVersion >= 1 && oldVersion < 2) {
26-
if (db.objectStoreNames.contains(NOTIFICATIONS_STORE)) {
35+
if (!db.objectStoreNames.contains(NOTIFICATIONS_STORE)) {
36+
const notifications = db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' });
37+
notifications.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId');
38+
} else {
2739
const notifications = request.transaction?.objectStore(NOTIFICATIONS_STORE);
2840
notifications?.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId');
2941
}
@@ -40,6 +52,33 @@ export function openHiveDB(): Promise<IDBDatabase> {
4052
}
4153
};
4254
request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed'));
43-
request.onsuccess = () => resolve(request.result);
55+
request.onblocked = () =>
56+
reject(
57+
new Error(
58+
'IndexedDB upgrade blocked by another open BeeBuzz tab or worker — please close other tabs'
59+
)
60+
);
61+
request.onsuccess = () => {
62+
resolve(attachVersionChangeClose(request.result));
63+
};
64+
});
65+
}
66+
67+
/**
68+
* Opens the current on-disk Hive database without requesting a version upgrade.
69+
*
70+
* Service-worker push/click handling uses this for stores that already existed
71+
* in v1 (device credentials, key material, and notification records). During a
72+
* staged rollout, an old foreground page can keep a v1 connection open; asking
73+
* the service worker for v2 in that moment can block decryption. Non-upgrading
74+
* opens keep notification delivery independent from the by-device index upgrade.
75+
*/
76+
export function openExistingHiveDB(): Promise<IDBDatabase> {
77+
return new Promise((resolve, reject) => {
78+
const request = indexedDB.open(HIVE_DB_NAME);
79+
request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed'));
80+
request.onsuccess = () => {
81+
resolve(attachVersionChangeClose(request.result));
82+
};
4483
});
4584
}

web/apps/hive/src/lib/services/notifications-repository.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { NOTIFICATIONS_BY_DEVICE_INDEX, NOTIFICATIONS_STORE, openHiveDB } from './hive-db';
1+
import {
2+
NOTIFICATIONS_BY_DEVICE_INDEX,
3+
NOTIFICATIONS_STORE,
4+
openExistingHiveDB,
5+
openHiveDB
6+
} from './hive-db';
27

38
export interface StoredNotificationRecord {
49
id: string;
@@ -31,7 +36,7 @@ function normalizeLegacyRecord(record: Record<string, unknown>): Record<string,
3136
export const notificationsRepository = {
3237
/** Persists one notification record to IndexedDB. */
3338
async save(input: StoredNotificationRecord): Promise<void> {
34-
const db = await openHiveDB();
39+
const db = await openExistingHiveDB();
3540

3641
return new Promise((resolve, reject) => {
3742
const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite');

web/apps/hive/src/lib/stores/notifications.svelte.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,27 +426,39 @@ export function formatTime(date: Date): string {
426426
return date.toLocaleTimeString('en-US', {
427427
hour: '2-digit',
428428
minute: '2-digit',
429-
second: '2-digit',
430429
hour12: false
431430
});
432431
}
433432

434433
export function formatRelativeTime(date: Date): string {
434+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
435+
const now = new Date();
436+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
437+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
438+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
439+
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
440+
const diffDays = Math.floor((today.getTime() - target.getTime()) / 86400000);
441+
442+
// Not today: show absolute time for clarity across midnight
443+
if (diffDays !== 0) {
444+
return formatTime(date);
445+
}
446+
435447
const diffMs = Date.now() - date.getTime();
448+
const diffSeconds = Math.floor(diffMs / 1000);
436449

437-
if (diffMs < 60000) {
450+
if (diffSeconds < 10) {
438451
return 'now';
439452
}
440453

454+
if (diffSeconds < 60) {
455+
return `${diffSeconds}s`;
456+
}
457+
441458
const diffMinutes = Math.floor(diffMs / 60000);
442459
if (diffMinutes < 60) {
443460
return `${diffMinutes}m`;
444461
}
445462

446-
const diffHours = Math.floor(diffMinutes / 60);
447-
if (diffHours < 24) {
448-
return `${diffHours}h`;
449-
}
450-
451463
return formatTime(date);
452464
}

web/apps/hive/src/routes/(app)/+layout.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@
8080
);
8181
} else if (event.data?.type === 'NOTIFICATION_CLICKED') {
8282
const clickedNotification = event.data.notification;
83+
// User tapped a system notification — always reload from IndexedDB
84+
// in case the postMessage for the original push didn't reach us
85+
// (common on iOS, and required when the click happens before the
86+
// app shell has activated a device id). Run this BEFORE any
87+
// device-id gating so the durable record is still recovered when
88+
// `deviceId` on the click message is missing or stale.
89+
void notificationsStore.loadFromIndexedDB();
8390
if (clickedNotification?.deviceId !== notificationsStore.activeDeviceId) return;
8491
if (
8592
clickedNotification?.id &&
@@ -98,9 +105,6 @@
98105
clickedNotification.id
99106
);
100107
}
101-
// User tapped a system notification — reload from IndexedDB in case
102-
// the postMessage for the original push didn't reach us (common on iOS).
103-
void notificationsStore.loadFromIndexedDB();
104108
} else if (event.data?.type === 'SUBSCRIPTION_CHANGED') {
105109
paired.clear();
106110
toast.info('Push subscription expired. Please reconnect.');

0 commit comments

Comments
 (0)