diff --git a/packages/snaps-controllers/CHANGELOG.md b/packages/snaps-controllers/CHANGELOG.md index e9c77b0cd4..90936d413b 100644 --- a/packages/snaps-controllers/CHANGELOG.md +++ b/packages/snaps-controllers/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Add `setEventDate` to `CronjobControllerStateManager` ([#4107](https://github.com/MetaMask/snaps/pull/4107)) + - `CronjobController` now persists a rescheduled event's next execution date through `setEventDate(id, date)` rather than passing the entire state to `set`. Rescheduling is the controller's most frequent write and changes only this field, so clients may now store dates separately and merge them back in `getInitialState`. + - Implementers of `CronjobControllerStateManager` must add the method. Delegating to `set` with the date applied preserves existing behaviour. + ## [21.1.0] ### Added diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 3ec745717c..3a55d4abea 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -23,16 +23,38 @@ const MOCK_VERSION = '1.0.0' as SemVerVersion; /** * Get a mock state manager for the `CronjobController`. * - * @returns A state manager object with `get` and `set` methods. + * @returns A state manager object with `getInitialState`, `set` and + * `setEventDate` methods. */ function getMockStateManager(): CronjobControllerStateManager { let state: CronjobControllerState | undefined; + // Dates are stored apart from the rest of the state, mirroring how a client + // is expected to implement this, and merged back on read. + const dates = new Map(); + return { - getInitialState: () => state, + getInitialState: () => { + if (!state) { + return undefined; + } + + return { + ...state, + events: Object.fromEntries( + Object.entries(state.events).map(([id, event]) => [ + id, + { ...event, date: dates.get(id) ?? event.date }, + ]), + ), + }; + }, set: (newState) => { state = newState; }, + setEventDate: (id, date) => { + dates.set(id, date); + }, }; } @@ -469,6 +491,59 @@ describe('CronjobController', () => { cronjobController.destroy(); }); + it('persists a reschedule through `setEventDate`, without rewriting all state', async () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const handleRequest = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + handleRequest, + ); + + const stateManager = getMockStateManager(); + const set = jest.spyOn(stateManager, 'set'); + const setEventDate = jest.spyOn(stateManager, 'setEventDate'); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + stateManager, + state: { + events: { + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + snapId: MOCK_SNAP_ID, + date: new Date('2022-01-01T00:00Z').toISOString(), + scheduledAt: new Date('2022-01-01T00:00Z').toISOString(), + schedule: 'PT25H', + recurring: true, + request: { + method: 'exampleMethod', + params: ['p1'], + }, + }, + }, + }, + }); + + cronjobController.init(); + + await new Promise((resolve) => originalProcessNextTick(resolve)); + expect(handleRequest).toHaveBeenCalledTimes(1); + + // Firing the event reschedules it, which is the write this change is + // about: one date, not the whole event map. + expect(setEventDate).toHaveBeenCalledWith( + `cronjob-${MOCK_SNAP_ID}-0`, + expect.any(String), + ); + + expect(set).not.toHaveBeenCalled(); + + cronjobController.destroy(); + }); + it('handles the `snapInstalled` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index 8a8ad0ca60..cf92079d5f 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -71,6 +71,20 @@ export const DAILY_TIMEOUT = inMilliseconds(24, Duration.Hour); export type CronjobControllerStateManager = { set(state: CronjobControllerState): void; + + /** + * Persist a single event's next execution date. + * + * Rescheduling is by far the most frequent write this controller makes — a + * snap with a `PT30S` schedule reschedules every thirty seconds — and it + * changes one field. Routing it here lets an implementation store dates + * separately instead of re-serialising every event on each tick. + * + * @param id - The ID of the event. + * @param date - The next execution date, as an ISO 8601 string. + */ + setEventDate(id: string, date: string): void; + getInitialState(): CronjobControllerState | undefined; }; @@ -389,11 +403,11 @@ export class CronjobController extends BaseController< } const date = getExecutionDate(event.schedule); - const { nextState } = this.update((state) => { + this.update((state) => { state.events[event.id].date = date; }); - this.#stateManager.set(nextState); + this.#stateManager.setEventDate(event.id, date); this.#startTimer({ ...event,