diff --git a/README.md b/README.md index c5d5f45..18be47f 100644 --- a/README.md +++ b/README.md @@ -8,274 +8,217 @@ Dynamically create, re-use, and destroy [Pinia](https://pinia.vuejs.org/) like s ## Primary Use Case When using non-trivial derived reactive objects in multiple components. +⭐️ Examples in this readme reference this case when it has `import { /* ... */ } from 'person-data.ts'` ```ts // person-data.ts +import { computed, ref, reactive, type Reactive } from 'vue' + type Person = { id: number, - name: string, + firstName: string, + lastName: string, } -export const people = ref([{ +type PersonInfo = { + id: ComputedRef, + firstName: Ref, + lastName: Ref, + fullName: ComputedRef, +} + +const people = ref([{ id: 99, - firstName: 'Jim', - lastName: 'Kirk' + firstName: 'Bobby', + lastName: 'Testerson' }]) + export const getPerson = (id: number) => people.value.find(person => person.id === id) +export const removePerson = (id: number) => { + const index = people.value.findIndex(person => person.id === id) + if (index > -1) { + people.value.splice(index, 1) + } +} -export const getPersonInfo = (person: Person) => { - const firstName = computed(() => person.firstName) - const lastName = computed(() => person.lastName) +export const getPersonInfo = (person: Person): PersonInfo => { + const { firstName, lastName } = toRefs(person) + // 🧠 imagine this is non-trivial and complicated 🧠 return { - id: computed(() => id), + id: computed(() => person.id), firstName, lastName, - fullName: computed(() => firstName.value + ' ' + lastName.value) + fullName: computed(() => firstName.value + ' ' + lastName.value), } } +``` + +### Generic Example + +```vue +// inside multiple vue components + + ``` -### Solution -Reusable non-trivial computed/reactive objects in multiple components. +### Vue Cache Store Solution ```ts // person-info.ts -import { computed } from 'vue' import { watchRecordStore } from 'vue-cache-store' -import { getPerson, getPersonInfo } from 'person-data.ts' +// see person-data.ts above ⬆️ +import { getPerson, getPersonInfo, type Person, type PersonInfo } from 'person-data.ts' -export const personInfo = watchRecordStore( - // record watcher +export const personInfo = watchRecordStore( + // watches for reactive changes // auto clears cached object if returns falsy (id: number) => getPerson(id), // cached object creator + // re-uses result on subsequent calls (person: Person) => getPersonInfo(person), ) ``` -```ts -// inside multiple components -import { personInfo } from 'person-info.ts' - -// returns reactive object -const reactivePerson = personInfo.get(id) -// ❌ dereferencing reactive objects breaks them -const { lastName } = personInfo.get(id) -// ✅ use the getRefs() instead -const { firstName, fullName } = personInfo.getRefs(id) - -const computedLastName = computed(() => personInfo.get(id).lastName) -``` - -## Usage - -### Define a Cache Store -Cache stores are designed to behave similar to [Pinia](https://pinia.vuejs.org/) stores. -The value returned by `usePersonCache().get(id)` can be used similar to a Pinia store. -```ts -// person-cache.ts -import { defineCacheStore } from 'vue-cache-store' -import { computed } from 'vue' - -// simplified data source -const people = ref([{ - id: 99, - firstName: 'Jim', - lastName: 'Kirk' -}]) - -const getPerson = (id: number) => people.value.find(person => person.id === id) - -export const usePersonCache = defineCacheStore((id) => { - const person = getPerson(id) - const firstName = computed(() => person.firstName) - const lastName = computed(() => person.lastName) - - return { - id: computed(() => id), - firstName, - lastName, - fullName: computed(() => firstName.value + ' ' + lastName.value) - } -}) -``` ```vue -// my-component.vue +// inside multiple vue components ``` -### Cache Store API -```ts -// person-cache.ts -import { defineCacheStore } from 'vue-cache-store' -import { type ToRefs, type Reactive} from 'vue' +## How It Works -export const usePersonCache = defineCacheStore((id: number): Item => { - return { - id, - name: 'sue', - // ... - } -}) +### Record Stores `makeRecordStore()` -type Item = { - id: number, - name: string, - //... -} +```ts +// person-info.ts +import { type ToRefs, type Reactive} from 'vue' +import { makeRecordStore } from 'vue-cache-store' +// see person-data.ts above ⬆️ +import { getPerson, getPersonInfo, type Person, type PersonInfo } from 'person-data.ts' // equivalent interface (not actual) type CacheStore = { // get cached ids - ids(): any[], - // get reactive object - get(id: any): Reactive, + ids(): number[], + // get reactive object like a pinia store + get(id: number): Reactive, // get refs wrapped object like pinia's storeToRefs(useMyStore()) - getRefs(id: any): ToRefs, + getRefs(id: number): ToRefs>, // check if id is cached - has(id: any): boolean, + has(id: number): boolean, + // loop over each cache object + forEach(callbackFunction: (value: Reactive, key: number) => void): void; // remove cached id - remove(id: any): void, - // get number of mounted components using this cache store - getUseCount(): number, + remove(id: number): void, // clear all cache ids clear(): void, - // increase use count by 1 - mount(): void, - // decrease use count by 1 - // if autoClearUnused option is true, - // calls clear(), clearing the whole store if count becomes 0 - unMount(): void, } -const cache: CacheStore = usePersonCache() -const personInfo = cache.get(99) +export const personInfo: CacheStore = makeRecordStore((id: number, context) => { + const person = getPerson(id) + + return getPersonInfo(person) +}) ``` -### Cache Store Context -The `context` argument is the current cache store instance. +### Record Store Context +The `context` argument is the current record store instance. ```ts -// person-cache.ts -import { defineCacheStore } from 'vue-cache-store' -import { getRecordInfo } from 'record-info-getter' -import { computed } from 'vue' - -export const usePersonCache = defineCacheStore((id, context: CacheStore) => { - const info = getRecordInfo(id) - const firstName = computed(() => info.firstName) - const lastName = computed(() => info.lastName) - const manager = context.get(info.managerId) - +// person-info.ts +import { makeRecordStore } from 'vue-cache-store' + +export const personInfo = makeRecordStore((id: number, context: CacheStore) => { + const person = getPerson(id) + + // 🧠 imagine a managerId property existed in the example above 🧠 + const managerInfo = context.get(person.managerId) + return { - id: computed(() => id), - firstName, - lastName, - fullName: computed(() => firstName.value + ' ' + lastName.value), - manager, + ...getPersonInfo(person), + manager: managerInfo, } }) ``` -### Record Stores - -Designed to cache an object store based on a record object. - -#### `defineRecordStore()` -Internally calls and returns `defineCacheStore()` +### Watch Record Stores `watchRecordStore()` ```ts // person-info.ts -import { computed, ref } from 'vue' -import { defineRecordStore } from 'vue-cache-store' - -// minimal example -const people = ref([{ - id: 99, - name: 'Jim', -}]) - -const getPerson = (id) => people.value.find(person => person.id === id) -const removePerson = (id) => { - const index = people.value.findIndex(person => person.id === id) - if (index > -1) { - people.value.splice(index, 1) - } -} -// defineRecordStore() internally calls and returns defineCacheStore() -export const usePersonInfo = defineRecordStore( - // record watcher - (id: number) => { - // this function is watched - // if the return value becomes falsy - // the cached object is removed automatically - return getPerson(id) - }, +import { watchRecordStore } from 'vue-cache-store' +// see person-data.ts above ⬆️ +import { getPerson, removePerson, getPersonInfo, type Person, type PersonInfo } from 'person-data.ts' +import { makeRecordStore } from './makeRecordStore' +export const personInfo = watchRecordStore( + // watches for reactive changes + // auto clears cached object if returns falsy + (id: number) => getPerson(id), // cached object creator - (record: Person) => { - // return value of this function is cached. - // even if used by multiple components - // it will not be called repeatedly - const { id: personId, name } = toRefs(record) - - return { - id: personId, - name, - nameLength: computed(() => record.name.length || 0), - } - }, + // re-uses result on subsequent calls + (person: Person) => getPersonInfo(person), ) -const personInfo = usePersonInfo() - -const person = personInfo.get(99) -person.name // 'Jim' -person.nameLength // 3 - -person.name = 'Jess' -person.name // 'Jess' -person.nameLength // 4 +// watchRecordStore() wraps makeRecordStore() +// with behavior equivalent to the following: +export const personInfoWatched = makeRecordStore((id: number) => { + const comp = computed(() => getRecord(id)) + watch(comp, () => { + if (!comp.value) { + context.remove(id) + } + }) -// dereference reactive to refs -const { name } = personInfo.getRefs(99) -name.value // 'Jess' -name.value = 'Ricky' -name.value // 'Ricky' + const record = comp.value + if (!record) { + throw new Error(`watchRecordStore(): Record id "${id}" not found.`) + } + return getPersonInfo(record) +}) -const samePerson = getPerson(99) as Person -samePerson.name // 'Ricky' +// this allows the cached info object to be automatically cleared +// when the person object it is based on is removed +const person = personInfo.get(99) // source record is removed removePerson(99) @@ -286,18 +229,6 @@ personInfo.has(99) // false personInfo.ids() // [] ``` -#### `watchRecordStore()` -```ts -import { watchRecordStore } from 'vue-cache-store' - -export const personInfo = watchRecordStore(/* ... */) - -// watchRecordStore() internally does the following: -const useInfo = defineRecordStore(/* ... */)() -// with typing intact -return useInfo() -``` - #### Usage within a [Pinia](https://pinia.vuejs.org/) store ```ts @@ -305,7 +236,6 @@ return useInfo() import { defineStore } from 'pinia' import { watchRecordStore } from 'vue-cache-store' -// minimal example type Person = { id: number, name: string, @@ -343,10 +273,10 @@ export const usePersonStore = defineStore('people', () => { (id: number) => getPerson(id), (record: Person) => { const person = computed(() => record) - const { id: personId, name } = toRefs(record) + const { name } = toRefs(record) return { - id: personId, + id: computed(() => person.id), name, nameLength: computed(() => person.value?.name.length || 0), } @@ -395,116 +325,6 @@ personStore.personInfo.has(99) // false personStore.personInfo.ids() // [] ``` -### Cache Store Options - -When defining a cache store the second argument is a default options object. - -| option | description | -|:----------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `autoMountAndUnMount` | If true, automatically tracks the number of mounted components using the cache store.
Mounting is tracked when calling in the root of a component. Example: `const personInfo = usePersonInfo()` | -| `autoClearUnused` | If true, when there are no longer any mounted components using a cache store it will be cleared. | - -#### `defineCacheStore()` Options -```ts -// global default option values -const options = { - autoMountAndUnMount: true, - autoClearUnused: true, -} -``` -#### `defineCacheStore()` Options Usage -```ts -// person-cache.ts -import { defineCacheStore } from 'vue-cache-store' - -// set new global defaults for all stores created with defineCacheStore() -defineCacheStore.setGlobalDefaultOptions({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// defining a cache store with store default options overriding global defaults -export const usePersonCache = defineCacheStore((id) => { - return { - // ... - } -}, { - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// inside a component -// overrides usePersonCache default options and defineCacheStore global defaults -const personCache = usePersonCache({ - autoMountAndUnMount: true, - autoClearUnused: false, -}) -``` - -#### `defineRecordStore()` Options -The intended use case for record stores removes cache objects when their source has been removed. -So its global defaults are different. -```ts -// global default option values -const options = { - autoMountAndUnMount: false, - autoClearUnused: false, -} -``` -#### `defineRecordStore()` Options Usage - -```ts -// person-record.ts -import { defineRecordStore } from 'vue-cache-store' - -// set new global defaults for all stores created with defineRecordStore() -defineRecordStore.setGlobalDefaultOptions({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// defining a record store with store default options overriding global defaults -export const usePersonRecord = defineRecordStore( - (id) => { - // ... - }, - () => { - // ... - }, - { - autoMountAndUnMount: false, - autoClearUnused: false, - }, -) - -// inside a component -// overrides usePersonRecord default options and defineRecordStore global defaults -const personCache = usePersonRecord({ - autoMountAndUnMount: true, - autoClearUnused: false, -}) -``` -#### `watchRecordStore()` Options -`watchRecordStore()` calls `defineRecordStore()` internally so it uses the global default options `defineRecordStore()` -```ts -import { watchRecordStore } from 'vue-cache-store' - -// defining a record store with store default options overriding global defaults -export const usePersonRecord = watchRecordStore( - (id) => { - // ... - }, - () => { - // ... - }, - { - autoMountAndUnMount: false, - autoClearUnused: false, - }, -) -``` - - ### API #### `reactiveToRefs()` diff --git a/package.json b/package.json index d735802..a26fa2e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vue-cache-store", "type": "module", - "version": "1.3.0", + "version": "2.0.0", "packageManager": "pnpm@10.13.1", "description": "Cache and re-use computed/reactive properties in vue", "author": { @@ -66,7 +66,7 @@ "rollup-plugin-esbuild": "^6.1.1", "rollup-plugin-typescript2": "^0.36.0", "typescript": "^5.5.4", - "vitest": "^3.2.4", + "vitest": "3.2.4", "vue": "^3.5.17" }, "repository": { diff --git a/src/defineCacheStore.ts b/src/defineCacheStore.ts deleted file mode 100644 index f37d619..0000000 --- a/src/defineCacheStore.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { onUnmounted, type Reactive, reactive, type ToRefs } from 'vue' -import { makeOptionsHelper, type Options, type RequiredOptions } from './storeOptions' -import { reactiveToRefs } from './reactiveToRefs' - -export interface CacheStore { - // get cached ids - ids(): any[], - // get reactive object - get(id: any): Reactive, - // get refs wrapped object like pinia's storeToRefs(useMyStore()) - getRefs(id: any): ToRefs>, - // check if id is cached - has(id: any): boolean, - // remove cached id - remove(id: any): void, - // get number of mounted components using this cache store - getUseCount(): number, - // clear all cache ids - clear(): void, - // increase use count by 1 - mount(): void, - // decrease use count by 1 - // if autoClearUnused option is true, - // calls clear(), clearing the whole store if count becomes 0 - unMount(): void, - options(): RequiredOptions, -} - -export type GenericCacheStoreFactory = ReturnType - -export type GenericCacheStore = ReturnType> - -export type CacheStoreFactory>) => ReturnType> = { - (options?: Options): CacheStore>; -} - -const optionsHelper = makeOptionsHelper({ - autoMountAndUnMount: true, - autoClearUnused: true, -}) - -defineCacheStore.setGlobalDefaultOptions = optionsHelper.set -defineCacheStore.getGlobalDefaultOptions = optionsHelper.get -defineCacheStore.resetGlobalDefaultOptions = optionsHelper.reset - -export function defineCacheStore< - C extends (id: any, context: CacheStore>) => ReturnType ->(creatorFunction: C, defaultOptions?: Options) { - type CacheStoreResult = CacheStore> - - const cache = new Map>>() - let count = 0 - - return (options?: Options) => { - const merged = optionsHelper.merge(defaultOptions, options) - return makeCacheStore(creatorFunction, merged) - } - - function makeCacheStore(creatorFunction: C, options: RequiredOptions): CacheStoreResult { - - function get(id: any): Reactive> { - let result = cache.get(id) - if (result) { - return result - } - - const object = creatorFunction(id, context) as object - result = reactive(object) as Reactive> - cache.set(id, result) - - return result - } - - const getRefs = (id: any) => { - const obj = get(id) as Reactive> - return reactiveToRefs(obj) - } - const mount = () => count++ - const unMount = () => { - count-- - if (options.autoClearUnused) { - if (count < 1) { - cache.clear() - } - } - } - if (options.autoMountAndUnMount) { - mount() - onUnmounted(unMount) - } - - const context: CacheStoreResult = { - ids: () => [...cache.keys()], - get, - getRefs, - has: (id: any) => cache.has(id), - getUseCount: () => count, - remove: (id: any) => cache.delete(id), - clear: () => cache.clear(), - mount, - unMount, - options: () => options, - } - - return context - } -} \ No newline at end of file diff --git a/src/defineRecordStore.ts b/src/defineRecordStore.ts deleted file mode 100644 index 1a49aef..0000000 --- a/src/defineRecordStore.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { watchEffect } from 'vue' -import { type CacheStore, defineCacheStore } from './defineCacheStore' -import { makeOptionsHelper, type Options } from './storeOptions' - -export function watchRecordStore< - C extends (record: REC, context: CacheStore>) => ReturnType, - G extends (id: any) => ReturnType, - REC = object & ReturnType, -> -( - getRecord: G, - create: C, - defaultOptions?: Options, -) { - return defineRecordStore(getRecord as G, create as C, defaultOptions)() -} - -const optionsHelper = makeOptionsHelper({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -defineRecordStore.setGlobalDefaultOptions = optionsHelper.set -defineRecordStore.getGlobalDefaultOptions = optionsHelper.get -defineRecordStore.resetGlobalDefaultOptions = optionsHelper.reset - -export function defineRecordStore< - C extends (record: REC, context: CacheStore>) => ReturnType, - G extends (id: any) => ReturnType, - REC = object & ReturnType, -> -( - getRecord: G, - create: C, - defaultOptions?: Options, -) { - const creatorFunction = (id: any, context: CacheStore>) => { - watchEffect(() => { - if (!getRecord(id)) { - context.remove(id) - } - }) - - const record = getRecord(id) - if (!record) { - throw new Error(`defineRecordStore(): Record id "${id}" not found.`) - } - return create(record as REC, context) - } - - const options = optionsHelper.merge(defaultOptions) - - return defineCacheStore(creatorFunction, options) -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d9e7d03..4ce6e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ export { - defineCacheStore, - type CacheStore, - type CacheStoreFactory, - type GenericCacheStore, - type GenericCacheStoreFactory, -} from './defineCacheStore' -export { defineRecordStore, watchRecordStore } from './defineRecordStore' -export { type Options } from './storeOptions' + makeRecordStore, + type GenericRecordStore, + type RecordStore, +} from './makeRecordStore' + +export { + watchRecordStore, +} from './watchRecordStore' \ No newline at end of file diff --git a/src/makeRecordStore.ts b/src/makeRecordStore.ts new file mode 100644 index 0000000..48d74ad --- /dev/null +++ b/src/makeRecordStore.ts @@ -0,0 +1,62 @@ +import { type Reactive, reactive, type ToRefs } from 'vue' +import { reactiveToRefs } from './reactiveToRefs' + +export type RecordStore, T extends object> = { + // get cached ids + ids(): ID[], + // get reactive object + get(id: ID): Reactive, + // get refs wrapped object like pinia's storeToRefs(useMyStore()) + getRefs(id: ID): ToRefs>, + // check if id is cached + has(id: ID): boolean, + // remove cached id + remove(id: ID): void, + // clear all cache ids + clear(): void, + // loop over each cached item + forEach(callbackFunction: (value: Reactive, key: ID) => void): void; +} + +export type GenericRecordStore = ReturnType + +export function makeRecordStore< + ID extends NonNullable, + T extends object, +>(creatorFunction: (id: ID, context: RecordStore) => T) { + type ReactiveResult = Reactive + + const cache = new Map() + + const get = (id: ID): ReactiveResult => { + let result = cache.get(id) + if (result) { + return result + } + + const object = creatorFunction(id, context) + result = reactive(object) as ReactiveResult + cache.set(id, result) + + return result + } + + const getRefs = (id: ID) => { + const obj = get(id) as ReactiveResult + return reactiveToRefs(obj) + } + + const context: RecordStore = { + ids: () => [...cache.keys()], + get, + getRefs, + has: (id: ID) => cache.has(id), + remove: (id: ID) => cache.delete(id), + clear: () => cache.clear(), + forEach: (callbackFunction: (value: ReactiveResult, key: ID) => void) => { + cache.forEach(callbackFunction) + } + } + + return context +} \ No newline at end of file diff --git a/src/reactiveToRefs.ts b/src/reactiveToRefs.ts index ad14833..9ab554a 100644 --- a/src/reactiveToRefs.ts +++ b/src/reactiveToRefs.ts @@ -1,6 +1,6 @@ -// based on pinia storeToRefs() import { computed, isReactive, isRef, toRaw, toRef, type ToRefs } from 'vue' +// based on pinia storeToRefs() export function reactiveToRefs(obj: T) { const rawStore = toRaw(obj) const refs = {} diff --git a/src/storeOptions.ts b/src/storeOptions.ts deleted file mode 100644 index 2224b3c..0000000 --- a/src/storeOptions.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type RequiredOptions = { - autoMountAndUnMount: boolean; - autoClearUnused: boolean; -} - -export type Options = { - autoMountAndUnMount?: boolean; - autoClearUnused?: boolean; -} - -export function makeOptionsHelper(globalDefaultOptions: RequiredOptions) { - const globalOptions = { - ...globalDefaultOptions, - } - - const initialState = { - ...globalDefaultOptions, - } - const set = (options: Options): void => { - Object.assign(globalOptions, options) - } - const get = (): RequiredOptions => globalOptions - const reset = (): void => set(initialState) - - return { - set, - get, - reset, - merge: (storeDefaultOptions?: Options, options?: Options) => { - return { - ...globalOptions, - ...storeDefaultOptions, - ...options, - } - }, - } -} \ No newline at end of file diff --git a/src/watchRecordStore.ts b/src/watchRecordStore.ts new file mode 100644 index 0000000..222f62b --- /dev/null +++ b/src/watchRecordStore.ts @@ -0,0 +1,32 @@ +import { computed, watch } from 'vue' +import { makeRecordStore, type RecordStore } from './makeRecordStore' + +export function watchRecordStore< + ID extends NonNullable = Parameters[0]>[0], + R extends object = NonNullable[0]>>, + T extends object = ReturnType[1]>, +> +( + getRecord: (id: ID) => R | undefined, + create: (record: R, context: RecordStore) => T, +) { + type Result = RecordStore + + const creatorFunction = (id: ID, context: Result) => { + + const comp = computed(() => getRecord(id)) + watch(comp, () => { + if (!comp.value) { + context.remove(id) + } + }) + + const record = comp.value + if (!record) { + throw new Error(`watchRecordStore(): Record id "${id}" not found.`) + } + return create(record, context) + } + + return makeRecordStore(creatorFunction) +} diff --git a/tests/defineCacheStore.options.test.ts b/tests/defineCacheStore.options.test.ts deleted file mode 100644 index 6781009..0000000 --- a/tests/defineCacheStore.options.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, expect, it, onTestFailed } from 'vitest' -import { mount } from '@vue/test-utils' -import { defineCacheStore, type GenericCacheStore } from '../src' -import type { Options, RequiredOptions } from '../src/storeOptions' - -const ID = 9 - - -describe('define cache store options', async () => { - it('default options', async () => { - expect( - defineCacheStore.getGlobalDefaultOptions(), - ).toEqual({ - autoMountAndUnMount: true, - autoClearUnused: true, - }) - }) - - const cases = [ - { - options: { - autoMountAndUnMount: true, - autoClearUnused: true, - }, - testFunc: test_autoMountAndUnMount_is_true_AND_autoClearUnused_is_true, - }, - { - options: { - autoMountAndUnMount: false, - autoClearUnused: true, - }, - testFunc: test_autoMountAndUnMount_is_false_AND_autoClearUnused_is_true, - }, - { - options: { - autoMountAndUnMount: false, - autoClearUnused: false, - }, - testFunc: test_autoMountAndUnMount_is_false_AND_autoClearUnused_is_false, - }, - { - options: { - autoMountAndUnMount: true, - autoClearUnused: false, - }, - testFunc: test_autoMountAndUnMount_is_true_AND_autoClearUnused_is_false, - }, - ] - - cases.forEach(({ - options, - testFunc, - }) => { - - const optionCases = permuteCase(options) - - optionCases.forEach(([ - globalOptions, - defaultOptions, - directOptions, - ]: [Options | undefined, Options | undefined, Options | undefined]) => { - - const undefinedOptions = { - autoMountAndUnMount: undefined, - autoClearUnused: undefined, - } - - const { - autoMountAndUnMount: globalAutomountAndUnmount, - autoClearUnused: globalAutoClearUnused, - } = globalOptions ?? undefinedOptions - - const { - autoMountAndUnMount: defaultAutomountAndUnmount, - autoClearUnused: defaultAutoClearUnused, - } = defaultOptions ?? undefinedOptions - - const { - autoMountAndUnMount: directAutomountAndUnmount, - autoClearUnused: directAutoClearUnused, - } = directOptions ?? undefinedOptions - - - const { - autoMountAndUnMount: expectedAutomountAndUnmount, - autoClearUnused: expectedAutoClearUnused, - } = options ?? undefinedOptions - - - let testName = 'options override case: ' - testName += `global: autoMountAndUnMount=${JSON.stringify(globalAutomountAndUnmount)},autoClearUnused=${JSON.stringify(globalAutoClearUnused)},` - testName += `default: autoMountAndUnMount=${JSON.stringify(defaultAutomountAndUnmount)},autoClearUnused=${JSON.stringify(defaultAutoClearUnused)},` - testName += `override: autoMountAndUnMount=${JSON.stringify(directAutomountAndUnmount)},autoClearUnused=${JSON.stringify(directAutoClearUnused)},` - testName += `expected: autoMountAndUnMount=${JSON.stringify(expectedAutomountAndUnmount)},autoClearUnused=${JSON.stringify(expectedAutoClearUnused)}` - - it(testName, async () => { - if (globalOptions) { - defineCacheStore.setGlobalDefaultOptions(globalOptions) - } - let useTestCache - if (defaultOptions) { - useTestCache = defineCacheStore((id) => { - return { id } - }, defaultOptions) - } else { - useTestCache = defineCacheStore((id) => { - return { id } - }) - } - - let component - - if (directOptions) { - component = makeComponentWithCacheOptions(useTestCache, directOptions) - } else { - component = makeComponentWithCache(useTestCache) - } - - const wrapper = mount(component) - - onTestFailed(() => { - console.table({ - globalOptions, - defaultOptions, - directOptions, - expectedOptions: options, - // @ts-expect-error - actualOptions: wrapper.vm.cache.options(), - }) - }) - - testFunc(wrapper.vm.cache) - // @ts-expect-error - expect(wrapper.vm.cache.options()).toEqual(options) - }) - }) - }) -}) - -function makeComponentWithCache(useTestCache: ReturnType) { - return { - setup() { - const cache = useTestCache() - cache.get(ID) - return { - cache, - } - }, - template: `something`, - } -} - -function makeComponentWithCacheOptions(useTestCache: ReturnType, options: Options) { - return { - setup() { - const cache = useTestCache(options) - cache.get(ID) - return { - cache, - } - }, - template: `something`, - } -} - - -function test_autoMountAndUnMount_is_false_AND_autoClearUnused_is_true(cache: GenericCacheStore) { - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([ID]) - - cache.mount() - expect(cache.getUseCount()).toBe(1) - cache.unMount() - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([]) -} - -function test_autoMountAndUnMount_is_false_AND_autoClearUnused_is_false(cache: GenericCacheStore) { - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([ID]) - - cache.mount() - expect(cache.getUseCount()).toBe(1) - cache.unMount() - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([ID]) -} - -function test_autoMountAndUnMount_is_true_AND_autoClearUnused_is_false(cache: GenericCacheStore) { - expect(cache.getUseCount()).toBe(1) - expect(cache.ids()).toEqual([ID]) - - cache.mount() - expect(cache.getUseCount()).toBe(2) - cache.unMount() - expect(cache.getUseCount()).toBe(1) - cache.unMount() - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([ID]) -} - -function test_autoMountAndUnMount_is_true_AND_autoClearUnused_is_true(cache: GenericCacheStore) { - expect(cache.getUseCount()).toBe(1) - expect(cache.ids()).toEqual([ID]) - - cache.mount() - expect(cache.getUseCount()).toBe(2) - cache.unMount() - expect(cache.getUseCount()).toBe(1) - expect(cache.ids()).toEqual([ID]) - cache.unMount() - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([]) -} - -function permuteCase(options: RequiredOptions) { - const p1 = globalPermutation() - const p2 = permutation() - const p3 = permutation() - - const cases: any[] = [] - p1.forEach((c1) => { - p2.forEach((c2) => { - p3.forEach((c3) => { - - const { - autoMountAndUnMount, - autoClearUnused, - } = Object.assign({}, c1, c2, c3) - - if (autoMountAndUnMount === options.autoMountAndUnMount && - autoClearUnused === options.autoClearUnused) { - cases.push([c1, c2, c3]) - } - }) - }) - }) - return cases -} - -function globalPermutation() { - - const values = [ - true, - false, - ] - - const result: Options[] = [] - values.forEach((value1) => { - values.forEach((value2) => { - result.push({ - autoMountAndUnMount: value1, - autoClearUnused: value2, - }) - - }) - }) - - return result -} - -function permutation() { - - const values = [ - true, - false, - ] - - const result: (Options | undefined)[] = [] - values.forEach((value1) => { - result.push({ - autoMountAndUnMount: value1, - }) - result.push({ - autoClearUnused: value1, - }) - - values.forEach((value2) => { - result.push({ - autoMountAndUnMount: value1, - autoClearUnused: value2, - }) - - }) - }) - - result.push(undefined) - - return result -} \ No newline at end of file diff --git a/tests/defineCacheStore.types.test.ts b/tests/defineCacheStore.types.test.ts deleted file mode 100644 index 61a7fe9..0000000 --- a/tests/defineCacheStore.types.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import { - type CacheStore, - type CacheStoreFactory, - defineCacheStore, - type GenericCacheStore, - type GenericCacheStoreFactory, -} from '../src' -import type { Reactive, ToRefs } from 'vue' -import type { Person } from './helpers/people' - -type Item = { - id: number, - name: string, -} - -describe('defineCacheStore() types', async () => { - it('check readme type explanation is accurate', async () => { - type CustomCacheStore = { - ids(): any[], - get(id: any): Reactive, - getRefs(id: any): ToRefs>, - has(id: any): boolean, - remove(id: any): void, - getUseCount(): number, - clear(): void, - mount(): void, - unMount(): void, - } - - const useTestCache = defineCacheStore((id: number, context: CacheStore): Item => { - return { - id, - name: 'susan', - } - }, { autoMountAndUnMount: false }) - - const cache: CustomCacheStore = useTestCache() - - expectTypeOf(cache).toEqualTypeOf() - }) - - - it('type CacheStoreFactory', async () => { - - function creatorFunction(id: number): Person { - return { - id, - name: 'susan', - } - } - - const useTestCache: CacheStoreFactory = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - expectTypeOf(useTestCache).toEqualTypeOf>() - - const cache = useTestCache() - expectTypeOf(cache.get(99)).toEqualTypeOf() - }) - - it('type GenericCacheStoreFactory', async () => { - function creatorFunction(id: number) { - return { - id, - name: 'susan', - } - } - - const useTestCache: GenericCacheStoreFactory = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - expectTypeOf(useTestCache).toEqualTypeOf() - }) - - it('type GenericCacheStore', async () => { - function creatorFunction(id: number) { - return { - id, - name: 'susan', - } - } - - const useTestCache = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - const store: GenericCacheStore = useTestCache() - expectTypeOf(store).toEqualTypeOf() - }) - - it('type CacheStore', () => { - let called = 0 - - function creatorFunction(id: number, context: CacheStore) { - called++ - expectTypeOf(context).toEqualTypeOf>() - return { - id, - name: 'susan', - } - } - - const useTestCache = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - - const cache = useTestCache() - - expectTypeOf(cache).toEqualTypeOf>() - - cache.get('asd') - expect(called).toEqual(1) - }) -}) \ No newline at end of file diff --git a/tests/defineRecordStore.test.ts b/tests/defineRecordStore.test.ts deleted file mode 100644 index a260b55..0000000 --- a/tests/defineRecordStore.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { defineRecordStore, watchRecordStore } from '../src' -import { createPinia, defineStore, setActivePinia, type StoreDefinition } from 'pinia' -import { computed, nextTick, ref, toRefs, toValue } from 'vue' -import { type ExtendedPeopleStore, type Person, usePeople } from './helpers/people' - -describe('pinia integration', async () => { - - it('default options', async () => { - expect( - defineRecordStore.getGlobalDefaultOptions(), - ).toEqual({ - autoMountAndUnMount: false, - autoClearUnused: false, - }) - }) - - it('test readme example', async () => { - type Person = { - id: number, - name: string, - } - const people = ref([{ - id: 99, - name: 'Jim', - }]) - - const getPerson = (id: number) => people.value.find(person => person.id === id) - - const removePerson = (id: number) => { - const index = people.value.findIndex(person => person.id === id) - if (index > -1) { - people.value.splice(index, 1) - } - } - - const usePersonInfo = defineRecordStore( - (id: number) => getPerson(id), - (record: Person) => { - const { id: personId, name } = toRefs(record) - - return { - id: personId, - name, - nameLength: computed(() => record.name.length || 0), - } - }, - ) - - const personInfo = usePersonInfo() - - const person = personInfo.get(99) - - expect(person.name).toBe('Jim') - expect(person.nameLength).toBe(3) - - person.name = 'Jess' - - expect(person.name).toBe('Jess') - expect(person.nameLength).toBe(4) - - const { name } = personInfo.getRefs(99) - expect(name.value).toBe('Jess') - - name.value = 'Ricky' - expect(name.value).toBe('Ricky') - - const samePerson = getPerson(99) as Person - expect(samePerson.name).toBe('Ricky') - - removePerson(99) - expect(people.value).toEqual([]) - - await nextTick() - expect(personInfo.has(99)).toBe(false) - expect(personInfo.ids()).toEqual([]) - }) - - it('test readme pinia example', async () => { - type Person = { - id: number, - name: string, - } - - const usePersonStore = defineStore('people', () => { - const people = ref([{ - id: 99, - name: 'Jim', - }]) - const peopleIdIncrement = ref(0) - - const getPerson = (id: number) => people.value.find(person => person.id === id) - const add = (name: string) => { - const id = peopleIdIncrement.value++ - people.value.push({ id, name }) - return id - } - const remove = (id: number) => { - const index = people.value.findIndex(person => person.id === id) - if (index > -1) { - people.value.splice(index, 1) - } - } - const update = (id: number, name: string) => { - const item = getPerson(id) - if (!item) { - throw new Error(`Item "${id}" not found`) - } - - item.name = name - } - - const personInfo = watchRecordStore( - (id: number) => getPerson(id), - (record: Person) => { - const person = computed(() => record) - const { id: personId, name } = toRefs(record) - - return { - id: personId, - name, - nameLength: computed(() => person.value?.name.length || 0), - } - }, - ) - - return { - people, - personInfo, - getPerson, - getInfo: (id: number) => personInfo.get(id), - getInfoRefs: (id: number) => personInfo.getRefs(id), - add, - remove, - update, - } - }) - - const pinia = createPinia() - setActivePinia(pinia) - - const personStore = usePersonStore() - - const person = personStore.getInfo(99) - - expect(person.name).toBe('Jim') - expect(person.nameLength).toBe(3) - - person.name = 'Jess' - - expect(person.name).toBe('Jess') - expect(person.nameLength).toBe(4) - - const { name } = personStore.getInfoRefs(99) - expect(name.value).toBe('Jess') - - name.value = 'Ricky' - expect(name.value).toBe('Ricky') - - const samePerson = personStore.getPerson(99) as Person - expect(samePerson.name).toBe('Ricky') - - personStore.remove(99) - expect(toValue(personStore.people)).toEqual([]) - - await nextTick() - expect(personStore.personInfo.has(99)).toBe(false) - expect(personStore.personInfo.ids()).toEqual([]) - }) - - it('cache record store inside of store', async () => { - - const usePeopleStore: StoreDefinition = defineStore('people', () => { - - const { - people, - getPerson, - add, - remove, - update, - } = usePeople() - - const personInfo = watchRecordStore( - (id: number) => { - return getPerson(id) - }, - (record: Person) => { - const person = computed(() => record) - const { id: personId, name } = toRefs(record) - - return { - id: personId, - name, - nameLength: computed(() => person.value?.name.length || 0), - } - }, - ) - - return { - people, - personInfo, - getPerson, - getInfo: (id: number) => personInfo.get(id), - add, - remove, - update, - } - }) - - const pinia = createPinia() - setActivePinia(pinia) - const store = usePeopleStore() as ExtendedPeopleStore - await test_store(store) - }) -}) - -async function test_store(store: ExtendedPeopleStore) { - - const id = store.add('jim') - - expect(store.getPerson(id)).toEqual({ id: id, name: 'jim' }) - expect(store.getInfo(id)).toEqual({ id: id, name: 'jim', nameLength: 3 }) - expect(store.personInfo.ids()).toEqual([id]) - expect(store.personInfo.has(id)).toEqual(true) - - store.update(id, 'jimmy') - - expect(store.getPerson(id)).toEqual({ id: 0, name: 'jimmy' }) - expect(store.getInfo(id)).toEqual({ id: 0, name: 'jimmy', nameLength: 5 }) - - const id2 = store.add('jennifer') - - expect(store.getPerson(id2)).toEqual({ id: id2, name: 'jennifer' }) - expect(store.getInfo(id2)).toEqual({ id: id2, name: 'jennifer', nameLength: 8 }) - expect(store.personInfo.ids()).toEqual([id, id2]) - expect(store.personInfo.has(id)).toEqual(true) - expect(store.personInfo.has(id2)).toEqual(true) - - store.remove(id) - - await nextTick() - - expect(store.getPerson(id)).toEqual(undefined) - - expect(store.personInfo.ids()).toEqual([id2]) - expect(store.personInfo.has(id)).toEqual(false) - - expect(store.getPerson(id2)).toEqual({ id: id2, name: 'jennifer' }) - expect(store.getInfo(id2)).toEqual({ id: id2, name: 'jennifer', nameLength: 8 }) - expect(store.personInfo.has(id2)).toEqual(true) - - expect(() => store.getInfo(id)).toThrowError(`defineRecordStore(): Record id "${id}" not found.`) -} \ No newline at end of file diff --git a/tests/helpers/people.ts b/tests/helpers/people.ts index 933f5ba..9255592 100644 --- a/tests/helpers/people.ts +++ b/tests/helpers/people.ts @@ -1,5 +1,5 @@ import type { Store } from 'pinia' -import type { CacheStore } from '../../src' +import type { RecordStore } from '../../src' import { ref } from 'vue' export type Person = { @@ -15,7 +15,7 @@ export type PeopleStore = ReturnType export type ExtendedPeopleStore = Store PersonInfo, - personInfo: CacheStore + personInfo: RecordStore }> export const usePeople = () => { const people = ref([]) diff --git a/tests/defineCacheStore.test.ts b/tests/makeRecordStore.component.test.ts similarity index 63% rename from tests/defineCacheStore.test.ts rename to tests/makeRecordStore.component.test.ts index a825eb1..8ee18a4 100644 --- a/tests/defineCacheStore.test.ts +++ b/tests/makeRecordStore.component.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest' -import { defineCacheStore } from '../src' +import { makeRecordStore } from '../src' import { mount } from '@vue/test-utils' -import { computed, nextTick, reactive, ref, toRef, toValue, watch } from 'vue' +import { computed, reactive, ref, toRef } from 'vue' describe('define cache store', async () => { it('ref()', async () => { const x = ref('a') - const useTestCache = defineCacheStore((id) => { + const cache = makeRecordStore((id) => { return { id: ref(id), x, @@ -18,7 +18,6 @@ describe('define cache store', async () => { const App = { setup() { - const cache = useTestCache() const comp = computed(() => cache.get(ID)) const item = cache.get(ID) const itemRefs = cache.getRefs(ID) @@ -65,20 +64,10 @@ describe('define cache store', async () => { testState(VAL2, ID) - const cache = wrapper.vm.cache as ReturnType - - expect(cache.getUseCount()).toBe(1) expect(cache.ids()).toEqual([ID]) expect(cache.has(ID)).toEqual(true) - wrapper.unmount() - - expect(cache.getUseCount()).toBe(0) - expect(cache.ids()).toEqual([]) - expect(cache.has(ID)).toEqual(false) - function testState(x: string, id: number) { - // @ts-expect-error expect(wrapper.vm.comp.value).toBe(undefined) // @ts-expect-error @@ -94,7 +83,6 @@ describe('define cache store', async () => { expect(wrapper.vm.item.x).toEqual(x) // @ts-expect-error expect(wrapper.vm.item.id).toEqual(id) - } }) @@ -108,7 +96,7 @@ describe('define cache store', async () => { set: (value => x2.value = value), }) - const useTestCache = defineCacheStore((id) => { + const cache = makeRecordStore((id) => { return { id: computed(() => id), c, @@ -120,7 +108,6 @@ describe('define cache store', async () => { const App = { setup() { - const cache = useTestCache() const comp = computed(() => cache.get(ID)) const item = cache.get(ID) const itemRefs = cache.getRefs(ID) @@ -190,7 +177,7 @@ describe('define cache store', async () => { it('reactive()', async () => { const r = reactive({ x: 'a' }) - const useTestCache = defineCacheStore((id) => { + const cache = makeRecordStore((id) => { return { r, } @@ -200,7 +187,6 @@ describe('define cache store', async () => { const App = { setup() { - const cache = useTestCache() const comp = computed(() => cache.get(ID)) const item = cache.get(ID) const { r } = cache.getRefs(ID) @@ -263,7 +249,7 @@ describe('define cache store', async () => { return data.value.find((item) => item.id === id) as Item } - const useTestCache = defineCacheStore((id) => { + const cache = makeRecordStore((id: string) => { const item = findItem(id) as Item return { id: ref(id), @@ -275,10 +261,8 @@ describe('define cache store', async () => { props: { cacheId: String, }, - setup(props: { cacheId: Number }) { + setup(props: { cacheId: string }) { const cacheId = computed(() => props.cacheId) - - const cache = useTestCache() const comp = computed(() => cache.get(cacheId.value)) const compRefs = computed(() => cache.getRefs(cacheId.value)) const name = computed({ @@ -375,184 +359,4 @@ describe('define cache store', async () => { } }) - it('only caches once', async () => { - const count = ref(0) - const useTestCache = defineCacheStore((id) => { - count.value++ - return { - count: computed(() => count), - } - }) - - const App = { - setup() { - const cache = useTestCache() - const { count } = cache.getRefs(99) - - return { - cache, - count, - } - }, - template: `{{count}}`, - } - - const wrapper = mount(App, {}) - const wrapper2 = mount(App, {}) - - const cache = wrapper.vm.cache as ReturnType - - // @ts-expect-error - expect(wrapper2.vm.count.value).toBe(1) - expect(cache.getUseCount()).toBe(2) - expect(cache.ids()).toEqual([99]) - expect(wrapper.text()).toEqual('1') - }) - - it('get other cached values', async () => { - const data: { [K: string]: { name: string } } = { - A: { name: 'Jim' }, - B: { name: 'Lisa' }, - C: { name: 'Susan' }, - } - - const useTestCache = defineCacheStore((id: string, { get }) => { - - return { - id: computed(() => id), - name: computed(() => data[id].name), - friend: computed(() => { - if (id === 'A') { - return get('B') - } - - if (id === 'B') { - return get('C') - } - }), - } - }) - - const App = { - props: { - itemId: String, - }, - setup(props: any) { - const cache = useTestCache() - - const { id, name, friend } = cache.getRefs(props.itemId) - - return { - id, - name, - friend, - } - }, - template: `something`, - } - - const wrapper = mount(App, { - props: { - itemId: 'A', - }, - }) - - expect(wrapper.vm.id).toEqual('A') - expect(wrapper.vm.name).toEqual(data['A'].name) - expect(wrapper.vm.friend).toEqual({ - id: 'B', - name: data['B'].name, - friend: { - id: 'C', - name: data['C'].name, - }, - }) - }) - - it('remove other cached values', async () => { - type Item = { - id: string; - name: string; - } - const data = ref([ - { - id: 'A', - name: 'Jim', - }, - { - id: 'B', - name: 'Lisa', - }, - ]) - - const useTestCache = defineCacheStore((id: string, { get, remove }) => { - - watch(data, (newValue) => { - const exists = newValue.find((item) => item.id === id) - if (!exists) { - remove(id) - } - - }, { deep: true }) - - return { - id: computed(() => id), - name: computed(() => { - const exists = data.value.find((item) => item.id === id) - return exists?.name - }), - } - }, { autoMountAndUnMount: false, autoClearUnused: false }) - - const cache = useTestCache() - - expect(cache.get('A')).toEqual({ - id: 'A', - name: 'Jim', - }) - - expect(cache.get('B')).toEqual({ - id: 'B', - name: 'Lisa', - }) - expect(cache.ids()).toEqual(['A', 'B']) - - data.value.splice(0, 1) - - expect(data.value).toEqual([{ id: 'B', name: 'Lisa' }]) - await nextTick() - expect(cache.ids()).toEqual(['B']) - - cache.clear() - expect(cache.ids()).toEqual([]) - }) - - it('can use has() and getUseCount() internally', async () => { - const useTestCache = defineCacheStore((id: string, { has, getUseCount }) => { - return { - id: computed(() => id), - hasA: computed(() => has('A')), - hasB: computed(() => has('B')), - count: computed(() => getUseCount()), - } - }, { autoMountAndUnMount: false, autoClearUnused: false }) - - const cache = useTestCache() - - expect(toValue(cache.get('A'))).toEqual({ - id: 'A', - hasA: true, - hasB: false, - count: 0, - }) - - cache.mount() - - expect(toValue(cache.get('B'))).toEqual({ - id: 'B', - hasA: true, - hasB: true, - count: 1, - }) - }) }) diff --git a/tests/makeRecordStore.test.ts b/tests/makeRecordStore.test.ts new file mode 100644 index 0000000..e55cda0 --- /dev/null +++ b/tests/makeRecordStore.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { makeRecordStore } from '../src' +import { computed, type ComputedRef, nextTick, ref, watch } from 'vue' + +describe('define cache store', async () => { + + it('only run once', async () => { + const count = ref(0) + const cache = makeRecordStore((id: number) => { + count.value++ + return { + count: computed(() => count), + } + }) + + expect(cache.get(99).count.value).toBe(1) + expect(cache.ids()).toEqual([99]) + + expect(cache.get(99).count.value).toBe(1) + expect(cache.ids()).toEqual([99]) + }) + + it('get other cached values', async () => { + const data: { [K: string]: { name: string } } = { + A: { name: 'Jim' }, + B: { name: 'Lisa' }, + C: { name: 'Susan' }, + } + + const cache = makeRecordStore((id: string, { get }) => { + return { + id: computed(() => id), + name: computed(() => data[id].name), + friend: computed(() => { + if (id === 'A') { + return get('B') + } + + if (id === 'B') { + return get('C') + } + }), + } + }) + + const { id, name, friend } = cache.getRefs('A') + + expect(id.value).toEqual('A') + expect(name.value).toEqual(data['A'].name) + expect(friend.value).toEqual({ + id: 'B', + name: data['B'].name, + friend: { + id: 'C', + name: data['C'].name, + }, + }) + + const person = cache.getRefs('B') + expect(person.id.value).toEqual('B') + expect(person.name.value).toEqual(data['B'].name) + expect(person.friend.value).toEqual({ + id: 'C', + name: data['C'].name, + }) + }) + + it('remove() other cached values', async () => { + type Item = { + readonly id: string; + name: string; + } + const data = ref([ + { + id: 'A', + name: 'Jim', + }, + { + id: 'B', + name: 'Lisa', + }, + ]) + + const cache = makeRecordStore((id: string, { remove }) => { + + watch(data, (newValue) => { + const exists = newValue.find((item) => item.id === id) + if (!exists) { + remove(id) + } + + }, { deep: true }) + + return { + id: computed(() => id), + name: computed(() => { + const exists = data.value.find((item) => item.id === id) + return exists?.name + }), + } + }) + + expect(cache.get('A')).toEqual({ + id: 'A', + name: 'Jim', + }) + + expect(cache.get('B')).toEqual({ + id: 'B', + name: 'Lisa', + }) + expect(cache.ids()).toEqual(['A', 'B']) + + data.value.splice(0, 1) + + expect(data.value).toEqual([{ id: 'B', name: 'Lisa' }]) + await nextTick() + expect(cache.ids()).toEqual(['B']) + + cache.clear() + expect(cache.ids()).toEqual([]) + }) + + it('can use has() ids() forEach()', async () => { + type Item = { + id: ComputedRef, + ids: ComputedRef, + hasA: ComputedRef, + hasB: ComputedRef, + } + + const cache = makeRecordStore((id: string, { has, ids }) => { + return { + id: computed(() => id), + ids: computed(() => ids()), + hasA: computed(() => has('A')), + hasB: computed(() => has('B')), + } + }) + + const itemA = cache.get('A') + expect(itemA.id).toEqual('A') + expect(itemA.ids).toEqual(['A']) + expect(itemA.hasA).toEqual(true) + expect(itemA.hasB).toEqual(false) + + let map: any[] = [] + + cache.forEach((value, key) => { + map.push({ + key, + value, + }) + }) + + expect(map).toEqual([ + { + key: 'A', + value: { + id: 'A', + ids: ['A'], + hasA: true, + hasB: false, + }, + }, + ]) + + const itemARefs = cache.getRefs('A') + expect(itemARefs.id.value).toEqual('A') + expect(itemARefs.ids.value).toEqual(['A']) + expect(itemARefs.hasA.value).toEqual(true) + expect(itemARefs.hasB.value).toEqual(false) + + const itemB = cache.get('B') + expect(itemB.id).toEqual('B') + expect(itemB.ids).toEqual(['A', 'B']) + expect(itemB.hasA).toEqual(true) + expect(itemB.hasB).toEqual(true) + + const itemBRefs = cache.getRefs('B') + expect(itemBRefs.id.value).toEqual('B') + expect(itemBRefs.ids.value).toEqual(['A', 'B']) + expect(itemBRefs.hasA.value).toEqual(true) + expect(itemBRefs.hasB.value).toEqual(true) + }) +}) diff --git a/tests/makeRecordStore.types.test.ts b/tests/makeRecordStore.types.test.ts new file mode 100644 index 0000000..04adac3 --- /dev/null +++ b/tests/makeRecordStore.types.test.ts @@ -0,0 +1,100 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { Reactive, ToRefs } from 'vue' +import { type GenericRecordStore, makeRecordStore, type RecordStore } from '../src' + +describe('defineCacheStore() types', async () => { + + it('type GenericCacheStore', async () => { + function creatorFunction(id: number) { + return { + id, + name: 'susan', + } + } + + const store: GenericRecordStore = makeRecordStore(creatorFunction) + expectTypeOf(store).toEqualTypeOf() + }) + + it('makeRecordStore() types: id number', () => { + + type ItemInfo = { + id: number, + name: string, + context: RecordStore + } + + const cache = makeRecordStore((id, context) => { + return { + id, + name: 'susan', + context, + } + }) + + expectTypeOf(cache).toEqualTypeOf>() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf(cache.get(99)).toEqualTypeOf() + expectTypeOf(cache.get(99)).toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>>() + expectTypeOf>().toEqualTypeOf() + + const context = cache.get(99).context + expectTypeOf(context).toEqualTypeOf>() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf(context.get(99)).toEqualTypeOf() + expectTypeOf(context.get(99)).toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>>() + expectTypeOf>().toEqualTypeOf() + + }) + + it('makeRecordStore() types: id string', () => { + + type ItemInfo = { + id: string, + name: string, + context: RecordStore + } + + const cache = makeRecordStore((id, context) => { + return { + id, + name: 'susan', + context, + } + }) + + expectTypeOf(cache).toEqualTypeOf>() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf(cache.get('A')).toEqualTypeOf() + expectTypeOf(cache.get('A')).toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>>() + expectTypeOf>().toEqualTypeOf() + + const context = cache.get('A').context + expectTypeOf(context).toEqualTypeOf>() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf(context.get('A')).toEqualTypeOf() + expectTypeOf(context.get('A')).toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>>() + expectTypeOf>().toEqualTypeOf() + + }) +}) \ No newline at end of file diff --git a/tests/readme-examples.test.ts b/tests/readme-examples.test.ts new file mode 100644 index 0000000..78039f6 --- /dev/null +++ b/tests/readme-examples.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { computed, type ComputedRef, nextTick, type Reactive, type Ref, ref, toRefs, type ToRefs, toValue } from 'vue' +import { makeRecordStore, type RecordStore, watchRecordStore } from '../src' +import { createPinia, defineStore, setActivePinia } from 'pinia' + +type Item = { + id: number, + name: string, +} + +describe('readme examples', async () => { + it('person data', async () => { + // person-data.ts + type Person = { + id: number, + firstName: string, + lastName: string, + } + + type PersonInfo = { + id: ComputedRef, + firstName: Ref, + lastName: Ref, + fullName: ComputedRef, + } + + const people = ref([{ + id: 99, + firstName: 'Jim', + lastName: 'Kirk', + }]) + const getPerson = (id: number) => people.value.find(person => person.id === id) + + const getPersonInfo = (person: Person): PersonInfo => { + const { firstName, lastName } = toRefs(person) + + // 🧠 imagine this is non-trivial and complicated 🧠 + return { + id: computed(() => person.id), + firstName, + lastName, + fullName: computed(() => firstName.value + ' ' + lastName.value), + } + } + + const person = getPerson(99) as Person + const info = getPersonInfo(person) + + expect(info.firstName.value).toBe('Jim') + expect(info.lastName.value).toBe('Kirk') + expect(info.fullName.value).toBe('Jim Kirk') + + info.firstName.value = 'Jess' + info.lastName.value = 'Jones' + + expect(info.firstName.value).toBe('Jess') + expect(info.lastName.value).toBe('Jones') + expect(info.fullName.value).toBe('Jess Jones') + + const personInfo = watchRecordStore( + // record watcher + // auto clears cached object if returns falsy + (id: number) => getPerson(id), + // cached object creator + (person: Person) => getPersonInfo(person), + ) + + const info2 = personInfo.get(99) + + expect(info2.firstName).toBe('Jess') + expect(info2.lastName).toBe('Jones') + expect(info2.fullName).toBe('Jess Jones') + + info2.firstName = 'Sam' + info2.lastName = 'Thompson' + + expect(info2.firstName).toBe('Sam') + expect(info2.lastName).toBe('Thompson') + expect(info2.fullName).toBe('Sam Thompson') + + }) + + it('check readme type explanation is accurate', async () => { + type CustomRecordStore = { + ids(): number[], + get(id: number): Reactive, + getRefs(id: number): ToRefs>, + has(id: number): boolean, + remove(id: number): void, + clear(): void, + forEach(callbackFunction: (value: Reactive, key: number) => void): void; + } + + const cache = makeRecordStore((id: number, context: RecordStore): Item => { + return { + id, + name: 'susan', + } + }) + + expectTypeOf(cache).toEqualTypeOf() + }) + + it('test readme pinia example', async () => { + type Person = { + id: number, + name: string, + } + + const usePersonStore = defineStore('people', () => { + const people = ref([{ + id: 99, + name: 'Jim', + }]) + const peopleIdIncrement = ref(0) + + const getPerson = (id: number) => people.value.find(person => person.id === id) + const add = (name: string) => { + const id = peopleIdIncrement.value++ + people.value.push({ id, name }) + return id + } + const remove = (id: number) => { + const index = people.value.findIndex(person => person.id === id) + if (index > -1) { + people.value.splice(index, 1) + } + } + const update = (id: number, name: string) => { + const item = getPerson(id) + if (!item) { + throw new Error(`Item "${id}" not found`) + } + + item.name = name + } + + const personInfo = watchRecordStore( + (id: number) => getPerson(id), + (record: Person) => { + const person = computed(() => record) + const { id: personId, name } = toRefs(record) + + return { + id: personId, + name, + nameLength: computed(() => person.value?.name.length || 0), + } + }, + ) + + return { + people, + personInfo, + getPerson, + getInfo: (id: number) => personInfo.get(id), + getInfoRefs: (id: number) => personInfo.getRefs(id), + add, + remove, + update, + } + }) + + const pinia = createPinia() + setActivePinia(pinia) + + const personStore = usePersonStore() + + const person = personStore.getInfo(99) + + expect(person.name).toBe('Jim') + expect(person.nameLength).toBe(3) + + person.name = 'Jess' + + expect(person.name).toBe('Jess') + expect(person.nameLength).toBe(4) + + const { name } = personStore.getInfoRefs(99) + expect(name.value).toBe('Jess') + + name.value = 'Ricky' + expect(name.value).toBe('Ricky') + + const samePerson = personStore.getPerson(99) as Person + expect(samePerson.name).toBe('Ricky') + + personStore.remove(99) + expect(toValue(personStore.people)).toEqual([]) + + await nextTick() + expect(personStore.personInfo.has(99)).toBe(false) + expect(personStore.personInfo.ids()).toEqual([]) + }) +}) \ No newline at end of file diff --git a/tests/storeOptions.test.ts b/tests/storeOptions.test.ts deleted file mode 100644 index 813420a..0000000 --- a/tests/storeOptions.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { makeOptionsHelper } from '../src/storeOptions' - -describe('makeOptionsHelper()', async () => { - it('set and reset defaults', async () => { - - const initialState = { - autoMountAndUnMount: true, - autoClearUnused: true, - } - const helper = makeOptionsHelper(initialState) - expect(helper.get()).toEqual(initialState) - - const newState = { - autoMountAndUnMount: false, - autoClearUnused: false, - } - helper.set(newState) - expect(helper.get()).toEqual(newState) - - helper.reset() - expect(helper.get()).toEqual(initialState) - }) - - it('initializing', async () => { - const initialState = { - autoMountAndUnMount: true, - autoClearUnused: true, - } - const helper = makeOptionsHelper(initialState) - helper.reset() - expect(helper.get()).toEqual(initialState) - }) -}) \ No newline at end of file diff --git a/tests/vitest-setup.ts b/tests/vitest-setup.ts index 724e5b6..5d9a976 100644 --- a/tests/vitest-setup.ts +++ b/tests/vitest-setup.ts @@ -1,15 +1,4 @@ -import { afterEach, beforeEach } from 'vitest' +import { afterEach } from 'vitest' import { enableAutoUnmount } from '@vue/test-utils' -import { defineCacheStore, defineRecordStore } from '../src' enableAutoUnmount(afterEach) - -beforeEach(() => { - defineRecordStore.resetGlobalDefaultOptions() - defineCacheStore.resetGlobalDefaultOptions() -}) - -afterEach(() => { - defineRecordStore.resetGlobalDefaultOptions() - defineCacheStore.resetGlobalDefaultOptions() -}) diff --git a/tests/watchRecordStore.examples.test.ts b/tests/watchRecordStore.examples.test.ts new file mode 100644 index 0000000..6b9c13d --- /dev/null +++ b/tests/watchRecordStore.examples.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { watchRecordStore } from '../src' +import { createPinia, defineStore, setActivePinia, type StoreDefinition } from 'pinia' +import { computed, nextTick, ref, toRefs, toValue } from 'vue' +import { type ExtendedPeopleStore, type Person, usePeople } from './helpers/people' + +describe('pinia integration', async () => { + + it('cache record store inside of store', async () => { + + const usePeopleStore: StoreDefinition = defineStore('people', () => { + + const { + people, + getPerson, + add, + remove, + update, + } = usePeople() + + const personInfo = watchRecordStore( + (id: number) => { + return getPerson(id) + }, + (record: Person) => { + const person = computed(() => record) + const { id: personId, name } = toRefs(record) + + return { + id: personId, + name, + nameLength: computed(() => person.value?.name.length || 0), + } + }, + ) + + return { + people, + personInfo, + getPerson, + getInfo: (id: number) => personInfo.get(id), + add, + remove, + update, + } + }) + + const pinia = createPinia() + setActivePinia(pinia) + const store = usePeopleStore() as ExtendedPeopleStore + await test_store(store) + }) +}) + +async function test_store(store: ExtendedPeopleStore) { + + const id = store.add('jim') + + expect(store.getPerson(id)).toEqual({ id: id, name: 'jim' }) + expect(store.getInfo(id)).toEqual({ id: id, name: 'jim', nameLength: 3 }) + expect(store.personInfo.ids()).toEqual([id]) + expect(store.personInfo.has(id)).toEqual(true) + + store.update(id, 'jimmy') + + expect(store.getPerson(id)).toEqual({ id: 0, name: 'jimmy' }) + expect(store.getInfo(id)).toEqual({ id: 0, name: 'jimmy', nameLength: 5 }) + + const id2 = store.add('jennifer') + + expect(store.getPerson(id2)).toEqual({ id: id2, name: 'jennifer' }) + expect(store.getInfo(id2)).toEqual({ id: id2, name: 'jennifer', nameLength: 8 }) + expect(store.personInfo.ids()).toEqual([id, id2]) + expect(store.personInfo.has(id)).toEqual(true) + expect(store.personInfo.has(id2)).toEqual(true) + + store.remove(id) + + await nextTick() + + expect(store.getPerson(id)).toEqual(undefined) + + expect(store.personInfo.ids()).toEqual([id2]) + expect(store.personInfo.has(id)).toEqual(false) + + expect(store.getPerson(id2)).toEqual({ id: id2, name: 'jennifer' }) + expect(store.getInfo(id2)).toEqual({ id: id2, name: 'jennifer', nameLength: 8 }) + expect(store.personInfo.has(id2)).toEqual(true) + + expect(() => store.getInfo(id)).toThrowError(`watchRecordStore(): Record id "${id}" not found.`) +} \ No newline at end of file