diff --git a/core/src/constants.ts b/core/src/constants.ts index c47c00d8..9832aef0 100644 --- a/core/src/constants.ts +++ b/core/src/constants.ts @@ -38,6 +38,11 @@ export const EVENTS = { }, } as const; +export const MUTATIONS = { + snapshot: 'core:snapshot', + reset: 'core:reset', +} as const; + export const PROVIDERS = { read: value => value, write: value => value, diff --git a/core/src/index.ts b/core/src/index.ts index 73d75f4b..05af72f8 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -70,7 +70,7 @@ function emitCreated(store: InternalStore, state: any): void { eventEmitter.once(EVENTS.core.installed, created); } -function getExtendedStore[]>(store: InternalStore, extensions: TExtensions): ReturnType> { +function getExtendedStore[]>(store: InternalStore, extensions: TExtensions): ReturnType> { return extensions.reduce((output, extension) => { let result = {}; @@ -185,6 +185,8 @@ export function createStore implements InternalStore { +export default class Store implements InternalStore { private options: InternalStoreOptions; private flags: Map; @@ -56,6 +66,7 @@ export default class Store implements InternalSt private isSuppressing: boolean; private readState: ReadState; private writeState: WriteState; + private initialState?: StoreSnapshot; public name: string; public registrations: StoreRegistrations; @@ -79,6 +90,9 @@ export default class Store implements InternalSt this.scope = effectScope(); this.writeState = reactive(state) as WriteState; this.readState = readonly(this.writeState) as ReadState; + + this.once(EVENTS.store.created, () => this.initialState = this.snapshot()); + this.on(EVENTS.devtools.reset, () => this.reset()); } public get allowsOverwrite(): boolean { @@ -266,6 +280,36 @@ export default class Store implements InternalSt return action; } + public snapshot(): StoreSnapshot { + const snapshot = clone(this.state); + + const apply = ( + branchAccessor: BranchAccessor = identity, + mutationName: string = MUTATIONS.snapshot) => { + this.write(mutationName, SENDER, state => { + if (!snapshot) { + return console.warn('Couldn\'t find snapshot for this operation!'); + } + + const source = branchAccessor(snapshot); + const target = branchAccessor(state); + + overwrite(target, clone(source), INTERNAL.pattern); + }); + }; + + return { + apply, + get state() { + return clone(snapshot); + }, + }; + } + + public reset(branchAccessor: BranchAccessor = identity) { + this.initialState?.apply(branchAccessor, MUTATIONS.reset); + } + public write(name: string, sender: string, mutator: Mutator, suppress?: boolean): TResult { const mutation = () => this.mutate(name, sender, mutator, undefined); diff --git a/core/src/types.ts b/core/src/types.ts index 19ead6f7..6c00ce35 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -22,6 +22,7 @@ export type Action = undefined extends TPayload ? (pay export type EventHandler = (payload?: EventPayload) => void; export type TriggerHandler = (data: TEventData) => void; export type Trigger = (name: string | string[], handler: TriggerHandler) => EventListener; +export type BranchAccessor = (state: ReadState | WriteState) => TBranchState; export type InternalStores = Map>; export type Extension = (store: InternalStore) => Record; export type ExtensionAPIs[]> = UnionToIntersection>; @@ -63,6 +64,11 @@ export interface StoreRegistration { producer: RegistrationValueProducer; } +export interface StoreSnapshot { + get state(): TState; + apply(branchCallback?: BranchAccessor, mutationName?: string): void; +} + export interface StoreBase { /** * Register a getter on this store @@ -111,6 +117,18 @@ export interface StoreBase { */ suppress(callback: () => TResult): TResult; + /** + * Take a snapshot of this store's current state + */ + snapshot(): StoreSnapshot; + + /** + * Reset this store back to it's intial state + * + * @param branchAccessor - An optional function that returns a sub-branch of state to reset + */ + reset(branchAccessor?: BranchAccessor): void; + /** * Destroy this store */ diff --git a/core/test/index.test.ts b/core/test/index.test.ts index f7196fbe..84bb45a4 100644 --- a/core/test/index.test.ts +++ b/core/test/index.test.ts @@ -2,9 +2,10 @@ import { EventEmitter, } from '../src/event-emitter'; -import { +import Harlem, { createStore, -} from '../src/index'; + INTERNAL, +} from '../src'; import { isRef, @@ -13,6 +14,7 @@ import { import { afterEach, + beforeAll, describe, expect, test, @@ -28,16 +30,20 @@ function getId() { } function getStore() { + const internalKey = `${INTERNAL.prefix}-test`; + const { - state, getter, mutation, action, ...store } = createStore('main', { id: 0, - firstName: 'John', - lastName: 'Smith', + details: { + firstName: 'John', + lastName: 'Smith', + }, + [internalKey]: 10, }, { allowOverwrite: false, // extensions: [ @@ -48,32 +54,30 @@ function getStore() { // ], }); - const fullName = getter('fullname', state => `${state.firstName} ${state.lastName}`); + const fullName = getter('fullname', ({ details }) => `${details.firstName} ${details.lastName}`); - const setId = mutation('set-id', state => { - const id = getId(); + const setId = mutation('set-id', (state, payload?: number) => { + const id = payload || getId(); state.id = id; return id; }); - const setFirstName = mutation('set-firstname', (state, payload) => { - state.firstName = payload; - }); - - const setLastName = mutation('set-lastname', (state, payload) => { - state.lastName = payload; + const setDetails = mutation('set-details', (state, payload: Partial) => { + state.details = { + ...state.details, + ...payload, + }; }); return { - state, getter, mutation, action, fullName, setId, - setFirstName, - setLastName, + setDetails, + internalKey, ...store, }; } @@ -82,6 +86,18 @@ describe('Harlem Core', () => { let store = getStore(); + beforeAll(() => { + const app = { + use: (plugin: any, options?: any) => { + if (plugin && plugin.install){ + plugin.install(app, options); + } + }, + }; + + app.use(Harlem); + }); + afterEach(() => { store?.destroy(); store = getStore(); @@ -123,7 +139,7 @@ describe('Harlem Core', () => { const duplicates = [ () => createStore('main', {}), () => getter('fullname', () => {}), - () => mutation('set-firstname', () => {}), + () => mutation('set-details', () => {}), ]; duplicates.forEach(invokee => { @@ -151,8 +167,10 @@ describe('Harlem Core', () => { state, } = store; - expect(state).toHaveProperty('firstName'); - expect(state).toHaveProperty('lastName'); + expect(state).toHaveProperty('id'); + expect(state).toHaveProperty('details'); + expect(state.details).toHaveProperty('firstName'); + expect(state.details).toHaveProperty('lastName'); }); test('Should be readonly', () => { @@ -163,9 +181,9 @@ describe('Harlem Core', () => { vi.spyOn(console, 'warn').getMockImplementation(); // @ts-expect-error This is readonly - state.firstName = 'Billy'; + state.details.firstName = 'Billy'; - expect(state.firstName).toBe('John'); + expect(state.details.firstName).toBe('John'); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('target is readonly'), expect.anything() @@ -191,15 +209,16 @@ describe('Harlem Core', () => { test('Should correctly mutate state', () => { const { state, - setFirstName, - setLastName, + setDetails, } = store; - setFirstName('Jane'); - setLastName('Doe'); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); - expect(state.firstName).toBe('Jane'); - expect(state.lastName).toBe('Doe'); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); }); test('Should return a result from a mutation', () => { @@ -244,16 +263,18 @@ describe('Harlem Core', () => { mutate(state => { state.id = id; - state.firstName = 'Jane'; - state.lastName = 'Doe'; + state.details = { + firstName: 'Jane', + lastName: 'Doe', + }; }); }); await loadDetails(51); expect(state.id).toBe(51); - expect(state.firstName).toBe('Jane'); - expect(state.lastName).toBe('Doe'); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); }); test('Should return a result from an action', async () => { @@ -271,8 +292,10 @@ describe('Harlem Core', () => { mutate(state => { state.id = id; - state.firstName = 'Jane'; - state.lastName = 'Doe'; + state.details = { + firstName: 'Jane', + lastName: 'Doe', + }; }); return id; @@ -281,8 +304,8 @@ describe('Harlem Core', () => { const id = await loadDetails(); expect(id).toBeTypeOf('number'); - expect(state.firstName).toBe('Jane'); - expect(state.lastName).toBe('Doe'); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); }); }); @@ -370,13 +393,164 @@ describe('Harlem Core', () => { }; const setRefToState = mutation('set-ref-to-state', (state, { firstName }) => { - state.firstName = firstName as unknown as string; + state.details.firstName = firstName as unknown as string; }); setRefToState(payload); - expect(isRef(state.firstName)).toBe(false); - expect(state.firstName).toBe('Jim'); + expect(isRef(state.details.firstName)).toBe(false); + expect(state.details.firstName).toBe('Jim'); + }); + + }); + + describe('Snapshots', () => { + + test('Should apply a snapshot', () => { + const { + state, + snapshot, + setId, + setDetails, + } = store; + + setId(5); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); + + const snap = snapshot(); + + setId(7); + setDetails({ + firstName: 'James', + lastName: 'Halpert', + }); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('James'); + expect(state.details.lastName).toBe('Halpert'); + + snap.apply(); + + expect(state.id).toBe(5); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); + }); + + test('Should apply a partial snapshot', () => { + const { + state, + setId, + setDetails, + snapshot, + } = store; + + setId(5); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); + + const snap = snapshot(); + + setId(7); + setDetails({ + firstName: 'James', + lastName: 'Halpert', + }); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('James'); + expect(state.details.lastName).toBe('Halpert'); + + snap.apply(state => state.details); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); + }); + + }); + + describe('Resets', () => { + + test('Should perform a basic reset', async () => { + const { + state, + reset, + setId, + setDetails, + } = store; + + setId(5); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); + + expect(state.id).toBe(5); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); + + reset(); + + expect(state.id).toBe(0); + expect(state.details.firstName).toBe('John'); + expect(state.details.lastName).toBe('Smith'); + }); + + test('Should perform a partial reset', () => { + const { + reset, + state, + setId, + setDetails, + } = store; + + setId(7); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); + + reset(state => state.details); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('John'); + expect(state.details.lastName).toBe('Smith'); + }); + + test('Should ignore internal properties', () => { + const { + reset, + state, + setId, + setDetails, + internalKey, + } = store; + + setId(7); + setDetails({ + firstName: 'Jane', + lastName: 'Doe', + }); + + expect(state.id).toBe(7); + expect(state.details.firstName).toBe('Jane'); + expect(state.details.lastName).toBe('Doe'); + + reset(); + + expect(state[internalKey]).toBe(10); + expect(state.id).toBe(0); + expect(state.details.firstName).toBe('John'); + expect(state.details.lastName).toBe('Smith'); }); });