fix: publish activity only after persistence

This commit is contained in:
2026-08-22 16:56:10 +02:00
parent e9bbb358aa
commit 54031dc8de
2 changed files with 117 additions and 32 deletions
+67
View File
@@ -0,0 +1,67 @@
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys';
import type { ActivityEvent } from '@/lnurlcash/storage';
import type * as StorageExports from '@/lnurlcash/storage';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
const persistence = vi.hoisted(() => ({
persistActivityEvent: vi.fn<(key: CryptoKey, event: ActivityEvent) => Promise<void>>(),
}));
vi.mock('@/lnurlcash/storage', async (importOriginal) => ({
...(await importOriginal<typeof StorageExports>()),
persistActivityEvent: persistence.persistActivityEvent,
}));
import { useActivityStore } from './activity';
const LINKING_KEY = new Uint8Array(32).fill(7);
beforeEach(() => {
vi.clearAllMocks();
stubLocalStorage();
setActivePinia(createPinia());
});
describe('activity store durability', () => {
it('publishes an event only after its encrypted write completes', async () => {
let finishWrite: (() => void) | undefined;
persistence.persistActivityEvent.mockReturnValue(
new Promise<void>((resolve) => {
finishWrite = resolve;
}),
);
const activity = useActivityStore();
await activity.loadFor(await deriveBearerAesKey(LINKING_KEY));
const logging = activity.log('receive', 'Received funds.', () => {
throw new Error('Unexpected persistence failure.');
});
expect(activity.events).toEqual([]);
finishWrite?.();
await logging;
expect(activity.events).toHaveLength(1);
});
it('rejects a failed write without leaving a false reactive event', async () => {
const writeError = new Error('activity storage unavailable');
persistence.persistActivityEvent.mockRejectedValue(writeError);
const activity = useActivityStore();
await activity.loadFor(await deriveBearerAesKey(LINKING_KEY));
let surfaced: Error | null = null;
await activity.log('receive', 'Received funds.', (error) => {
surfaced = error;
});
expect(activity.events).toEqual([]);
expect(surfaced).toMatchObject({
name: 'ActivityPersistenceError',
actionCommitted: true,
cause: writeError,
});
});
});
+50 -32
View File
@@ -1,60 +1,78 @@
import {ref} from 'vue' import { ref } from 'vue';
import {defineStore} from 'pinia' import { defineStore } from 'pinia';
import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage' import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
import { import {
loadActivity, loadActivity,
persistActivityEvent, persistActivityEvent,
clearAllActivity, clearAllActivity,
newActivityId, newActivityId,
MAX_ACTIVITY_ENTRIES MAX_ACTIVITY_ENTRIES,
} from '@/lnurlcash/storage' } from '@/lnurlcash/storage';
export class ActivityPersistenceError extends Error {
override readonly name = 'ActivityPersistenceError';
readonly actionCommitted = true;
constructor(options: { cause: unknown }) {
super(
'The wallet action completed, but activity history could not be saved. Do not retry the action.',
options,
);
}
}
// The activity log: append-only, encrypted at rest with the same // The activity log: append-only, encrypted at rest with the same
// bearer-AES key as the notes themselves. Loaded by the wallet store on // bearer-AES key as the notes themselves. Loaded by the wallet store on
// unlock (loadFor) and dropped on lock (unload) - it never holds plaintext // unlock (loadFor) and dropped on lock (unload) - it never holds plaintext
// while the wallet is locked. // while the wallet is locked.
export const useActivityStore = defineStore('activity', () => { export const useActivityStore = defineStore('activity', () => {
const events = ref<ActivityEvent[]>([]) const events = ref<ActivityEvent[]>([]);
let aesKey: CryptoKey | null = null let aesKey: CryptoKey | null = null;
const loadFor = async (key: CryptoKey): Promise<void> => { const loadFor = async (key: CryptoKey): Promise<void> => {
aesKey = key aesKey = key;
events.value = await loadActivity(key) events.value = await loadActivity(key);
} };
const unload = (): void => { const unload = (): void => {
aesKey = null aesKey = null;
events.value = [] events.value = [];
} };
// both unload and wipe the stored log - part of forgetting a wallet // both unload and wipe the stored log - part of forgetting a wallet
const unloadAndClear = (): void => { const unloadAndClear = (): void => {
clearAllActivity() clearAllActivity();
unload() unload();
} };
// best-effort and silent on failure - a wallet action that already const log = async (
// succeeded (the note was split/melted/whatever) must never surface an kind: ActivityKind,
// error just because the log entry for it couldn't be written message: string,
const log = (kind: ActivityKind, message: string): void => { onPersistenceError: (error: ActivityPersistenceError) => void,
if (!aesKey) return ): Promise<void> => {
if (!aesKey) return;
const event: ActivityEvent = { const event: ActivityEvent = {
id: newActivityId(), id: newActivityId(),
kind, kind,
message, message,
createdAt: Date.now() createdAt: Date.now(),
} };
events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES) try {
persistActivityEvent(aesKey, event).catch(() => { await persistActivityEvent(aesKey, event);
// deliberately swallowed - see the comment above } catch (error) {
}) const cause =
error instanceof Error ? error : new Error('Activity storage failed.', { cause: error });
onPersistenceError(new ActivityPersistenceError({ cause }));
return;
} }
events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES);
};
const clear = (): void => { const clear = (): void => {
clearAllActivity() clearAllActivity();
events.value = [] events.value = [];
} };
return {events, loadFor, unload, unloadAndClear, log, clear} return { events, loadFor, unload, unloadAndClear, log, clear };
}) });