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
49 changes: 49 additions & 0 deletions web/apps/hive/src/lib/services/app-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,53 @@ describe('bootstrapAppShell', () => {
'postChecks'
]);
});

it('propagates legacy notification migration failures before draining IndexedDB', async () => {
const calls: string[] = [];
const failure = new Error('migration failed');

await expect(
bootstrapAppShell({
registerServiceWorker: () => {
calls.push('register');
return Promise.resolve({ scope: '/' });
},
checkPaired: () => {
calls.push('checkPaired');
return Promise.resolve(true);
},
getDeviceId: () => {
calls.push('getDeviceId');
return Promise.resolve('dev-a');
},
activateNotifications: (deviceId) => {
calls.push(`activate:${deviceId}`);
},
attachServiceWorkerListeners: () => {
calls.push('attach');
},
migrateLegacyNotifications: (deviceId) => {
calls.push(`migrate:${deviceId}`);
return Promise.reject(failure);
},
loadPersistedNotifications: (phase) => {
calls.push(`load:${phase}`);
return Promise.resolve();
},
runPostPairingChecks: () => {
calls.push('postChecks');
return Promise.resolve();
}
})
).rejects.toBe(failure);

expect(calls).toEqual([
'register',
'checkPaired',
'getDeviceId',
'activate:dev-a',
'attach',
'migrate:dev-a'
]);
});
});
7 changes: 4 additions & 3 deletions web/apps/hive/src/lib/services/device-keys-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ENCRYPTED_KEY_STORE,
ENCRYPTION_METADATA_STORE,
WRAPPING_KEY_STORE,
openExistingHiveDB,
openHiveDB
} from './hive-db';

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

return new Promise((resolve, reject) => {
const tx = db.transaction(ENCRYPTION_METADATA_STORE, 'readonly');
Expand Down Expand Up @@ -104,7 +105,7 @@ export const deviceKeysRepository = {
wrappingKey: CryptoKey | null;
wrappedPrivateKey: WrappedPrivateKeyRecord | null;
}> {
const db = await openHiveDB();
const db = await openExistingHiveDB();
const [wrappingKey, wrappedPrivateKey] = await Promise.all([
getStoreValue<CryptoKey>(db, WRAPPING_KEY_STORE, keyId, 'Wrapping key fetch failed'),
getStoreValue<WrappedPrivateKeyRecord>(
Expand Down Expand Up @@ -194,7 +195,7 @@ export const deviceKeysRepository = {

/** Retrieves stored device credentials, if available. */
async getDeviceCredentials(): Promise<DeviceCredentials | null> {
const db = await openHiveDB();
const db = await openExistingHiveDB();
return getStoreValue<DeviceCredentials>(
db,
ENCRYPTION_METADATA_STORE,
Expand Down
76 changes: 76 additions & 0 deletions web/apps/hive/src/lib/services/hive-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
ENCRYPTED_KEY_STORE,
ENCRYPTION_METADATA_STORE,
HIVE_DB_NAME,
NOTIFICATIONS_BY_DEVICE_INDEX,
NOTIFICATIONS_STORE,
WRAPPING_KEY_STORE,
openExistingHiveDB,
openHiveDB
} from './hive-db';

function deleteHiveDB(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(HIVE_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(request.error?.message ?? 'Delete failed'));
request.onblocked = () => reject(new Error('Delete blocked'));
});
}

function createV1HiveDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(HIVE_DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' });
db.createObjectStore(ENCRYPTION_METADATA_STORE, { keyPath: 'id' });
db.createObjectStore(WRAPPING_KEY_STORE);
db.createObjectStore(ENCRYPTED_KEY_STORE, { keyPath: 'id' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error(request.error?.message ?? 'Open failed'));
});
}

describe('hive-db', () => {
beforeEach(async () => {
await deleteHiveDB();
});

it('opens an existing v1 database without forcing the v2 upgrade', async () => {
const v1DB = await createV1HiveDB();
v1DB.close();

const existing = await openExistingHiveDB();

expect(existing.version).toBe(1);
expect(existing.objectStoreNames.contains(ENCRYPTION_METADATA_STORE)).toBe(true);
existing.close();
});

it('creates an empty v1 database when no Hive database exists yet', async () => {
const existing = await openExistingHiveDB();

expect(existing.version).toBe(1);
expect(existing.objectStoreNames.length).toBe(0);
existing.close();
});

it('creates missing stores when upgrading a sparse existing database to v2', async () => {
const sparseDB = await openExistingHiveDB();
expect(sparseDB.version).toBe(1);
sparseDB.close();

const upgraded = await openHiveDB();

expect(upgraded.version).toBe(2);
expect(upgraded.objectStoreNames.contains(NOTIFICATIONS_STORE)).toBe(true);
const tx = upgraded.transaction(NOTIFICATIONS_STORE, 'readonly');
expect(
tx.objectStore(NOTIFICATIONS_STORE).indexNames.contains(NOTIFICATIONS_BY_DEVICE_INDEX)
).toBe(true);
upgraded.close();
});
});
45 changes: 42 additions & 3 deletions web/apps/hive/src/lib/services/hive-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ export const ENCRYPTION_METADATA_STORE = 'encryption_keys';
export const WRAPPING_KEY_STORE = 'wrapping_keys';
export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys';

/** Opens the shared Hive IndexedDB database and creates required stores. */
function attachVersionChangeClose(db: IDBDatabase): IDBDatabase {
// If another context (page or service worker) needs to upgrade, close
// this connection so the upgrade is not blocked.
db.onversionchange = () => {
db.close();
};
return db;
}

/** Opens the shared Hive IndexedDB database and creates or upgrades required stores. */
export function openHiveDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(HIVE_DB_NAME, HIVE_DB_VERSION);
Expand All @@ -23,7 +32,10 @@ export function openHiveDB(): Promise<IDBDatabase> {
// v1 -> v2: per-device scoping was introduced. Add the by-device index to
// the existing store so legacy rows can be migrated lazily by the app shell.
if (oldVersion >= 1 && oldVersion < 2) {
if (db.objectStoreNames.contains(NOTIFICATIONS_STORE)) {
if (!db.objectStoreNames.contains(NOTIFICATIONS_STORE)) {
const notifications = db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' });
notifications.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId');
} else {
const notifications = request.transaction?.objectStore(NOTIFICATIONS_STORE);
notifications?.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId');
}
Expand All @@ -40,6 +52,33 @@ export function openHiveDB(): Promise<IDBDatabase> {
}
};
request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed'));
request.onsuccess = () => resolve(request.result);
request.onblocked = () =>
reject(
new Error(
'IndexedDB upgrade blocked by another open BeeBuzz tab or worker — please close other tabs'
)
);
request.onsuccess = () => {
resolve(attachVersionChangeClose(request.result));
};
});
}

/**
* Opens the current on-disk Hive database without requesting a version upgrade.
*
* Service-worker push/click handling uses this for stores that already existed
* in v1 (device credentials, key material, and notification records). During a
* staged rollout, an old foreground page can keep a v1 connection open; asking
* the service worker for v2 in that moment can block decryption. Non-upgrading
* opens keep notification delivery independent from the by-device index upgrade.
*/
export function openExistingHiveDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(HIVE_DB_NAME);
request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed'));
request.onsuccess = () => {
resolve(attachVersionChangeClose(request.result));
};
});
}
9 changes: 7 additions & 2 deletions web/apps/hive/src/lib/services/notifications-repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { NOTIFICATIONS_BY_DEVICE_INDEX, NOTIFICATIONS_STORE, openHiveDB } from './hive-db';
import {
NOTIFICATIONS_BY_DEVICE_INDEX,
NOTIFICATIONS_STORE,
openExistingHiveDB,
openHiveDB
} from './hive-db';

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

return new Promise((resolve, reject) => {
const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite');
Expand Down
26 changes: 19 additions & 7 deletions web/apps/hive/src/lib/stores/notifications.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,27 +426,39 @@ export function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}

export function formatRelativeTime(date: Date): string {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
const now = new Date();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.floor((today.getTime() - target.getTime()) / 86400000);

// Not today: show absolute time for clarity across midnight
if (diffDays !== 0) {
return formatTime(date);
}

const diffMs = Date.now() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);

if (diffMs < 60000) {
if (diffSeconds < 10) {
return 'now';
}

if (diffSeconds < 60) {
return `${diffSeconds}s`;
}

const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 60) {
return `${diffMinutes}m`;
}

const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours}h`;
}

return formatTime(date);
}
10 changes: 7 additions & 3 deletions web/apps/hive/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@
);
} else if (event.data?.type === 'NOTIFICATION_CLICKED') {
const clickedNotification = event.data.notification;
// User tapped a system notification — always reload from IndexedDB
// in case the postMessage for the original push didn't reach us
// (common on iOS, and required when the click happens before the
// app shell has activated a device id). Run this BEFORE any
// device-id gating so the durable record is still recovered when
// `deviceId` on the click message is missing or stale.
void notificationsStore.loadFromIndexedDB();
if (clickedNotification?.deviceId !== notificationsStore.activeDeviceId) return;
if (
clickedNotification?.id &&
Expand All @@ -98,9 +105,6 @@
clickedNotification.id
);
}
// User tapped a system notification — reload from IndexedDB in case
// the postMessage for the original push didn't reach us (common on iOS).
void notificationsStore.loadFromIndexedDB();
} else if (event.data?.type === 'SUBSCRIPTION_CHANGED') {
paired.clear();
toast.info('Push subscription expired. Please reconnect.');
Expand Down
Loading