mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: publish activity only after persistence
This commit is contained in:
@@ -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
@@ -1,60 +1,78 @@
|
||||
import {ref} from 'vue'
|
||||
import {defineStore} from 'pinia'
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage'
|
||||
import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
|
||||
import {
|
||||
loadActivity,
|
||||
persistActivityEvent,
|
||||
clearAllActivity,
|
||||
newActivityId,
|
||||
MAX_ACTIVITY_ENTRIES
|
||||
} from '@/lnurlcash/storage'
|
||||
MAX_ACTIVITY_ENTRIES,
|
||||
} 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
|
||||
// bearer-AES key as the notes themselves. Loaded by the wallet store on
|
||||
// unlock (loadFor) and dropped on lock (unload) - it never holds plaintext
|
||||
// while the wallet is locked.
|
||||
export const useActivityStore = defineStore('activity', () => {
|
||||
const events = ref<ActivityEvent[]>([])
|
||||
let aesKey: CryptoKey | null = null
|
||||
const events = ref<ActivityEvent[]>([]);
|
||||
let aesKey: CryptoKey | null = null;
|
||||
|
||||
const loadFor = async (key: CryptoKey): Promise<void> => {
|
||||
aesKey = key
|
||||
events.value = await loadActivity(key)
|
||||
}
|
||||
aesKey = key;
|
||||
events.value = await loadActivity(key);
|
||||
};
|
||||
|
||||
const unload = (): void => {
|
||||
aesKey = null
|
||||
events.value = []
|
||||
}
|
||||
aesKey = null;
|
||||
events.value = [];
|
||||
};
|
||||
|
||||
// both unload and wipe the stored log - part of forgetting a wallet
|
||||
const unloadAndClear = (): void => {
|
||||
clearAllActivity()
|
||||
unload()
|
||||
}
|
||||
clearAllActivity();
|
||||
unload();
|
||||
};
|
||||
|
||||
// best-effort and silent on failure - a wallet action that already
|
||||
// succeeded (the note was split/melted/whatever) must never surface an
|
||||
// error just because the log entry for it couldn't be written
|
||||
const log = (kind: ActivityKind, message: string): void => {
|
||||
if (!aesKey) return
|
||||
const log = async (
|
||||
kind: ActivityKind,
|
||||
message: string,
|
||||
onPersistenceError: (error: ActivityPersistenceError) => void,
|
||||
): Promise<void> => {
|
||||
if (!aesKey) return;
|
||||
const event: ActivityEvent = {
|
||||
id: newActivityId(),
|
||||
kind,
|
||||
message,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES)
|
||||
persistActivityEvent(aesKey, event).catch(() => {
|
||||
// deliberately swallowed - see the comment above
|
||||
})
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
try {
|
||||
await persistActivityEvent(aesKey, event);
|
||||
} 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 => {
|
||||
clearAllActivity()
|
||||
events.value = []
|
||||
}
|
||||
clearAllActivity();
|
||||
events.value = [];
|
||||
};
|
||||
|
||||
return {events, loadFor, unload, unloadAndClear, log, clear}
|
||||
})
|
||||
return { events, loadFor, unload, unloadAndClear, log, clear };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user