From 89d1949b81e030ef73d9aabc920fe7e58723fc11 Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Thu, 31 Jul 2025 21:11:32 -0400 Subject: [PATCH 1/9] initial v2 --- src/defineCacheStore.ts | 107 ------- src/defineRecordStore.ts | 92 +++--- src/index.ts | 16 +- src/storeOptions.ts | 37 --- src/watchRecordStore.ts | 28 ++ tests/defineCacheStore.options.test.ts | 290 ------------------ tests/defineCacheStore.test.ts | 54 +--- tests/defineCacheStore.types.test.ts | 62 +--- ....ts => defineRecordStore.examples.test.ts} | 17 +- tests/helpers/people.ts | 4 +- tests/storeOptions.test.ts | 34 -- tests/vitest-setup.ts | 13 +- 12 files changed, 110 insertions(+), 644 deletions(-) delete mode 100644 src/defineCacheStore.ts delete mode 100644 src/storeOptions.ts create mode 100644 src/watchRecordStore.ts delete mode 100644 tests/defineCacheStore.options.test.ts rename tests/{defineRecordStore.test.ts => defineRecordStore.examples.test.ts} (93%) delete mode 100644 tests/storeOptions.test.ts 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 index 1a49aef..87f70a7 100644 --- a/src/defineRecordStore.ts +++ b/src/defineRecordStore.ts @@ -1,54 +1,56 @@ -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)() -} +import { type Reactive, reactive, type ToRefs } from 'vue' +import { reactiveToRefs } from './reactiveToRefs' -const optionsHelper = makeOptionsHelper({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) +export interface RecordStore { + // 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, + // clear all cache ids + clear(): void, +} -defineRecordStore.setGlobalDefaultOptions = optionsHelper.set -defineRecordStore.getGlobalDefaultOptions = optionsHelper.get -defineRecordStore.resetGlobalDefaultOptions = optionsHelper.reset +export type GenericRecordStore = ReturnType 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.`) + C extends (id: any, context: RecordStore>) => ReturnType +>(creatorFunction: C) { + type RecordStoreResult = RecordStore> + + const cache = new Map>>() + + function get(id: any): Reactive> { + let result = cache.get(id) + if (result) { + return result } - return create(record as REC, context) + + 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 options = optionsHelper.merge(defaultOptions) + const context: RecordStoreResult = { + ids: () => [...cache.keys()], + get, + getRefs, + has: (id: any) => cache.has(id), + remove: (id: any) => cache.delete(id), + clear: () => cache.clear(), + } - return defineCacheStore(creatorFunction, options) + return context } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d9e7d03..b20fec0 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' + defineRecordStore, + type GenericRecordStore, + type RecordStore, +} from './defineRecordStore' + +export { + watchRecordStore, +} from './watchRecordStore' \ No newline at end of file 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..6f93be8 --- /dev/null +++ b/src/watchRecordStore.ts @@ -0,0 +1,28 @@ +import { watchEffect } from 'vue' +import { defineRecordStore, type RecordStore } from './defineRecordStore' + +export function watchRecordStore< + C extends (record: REC, context: RecordStore>) => ReturnType, + G extends (id: any) => ReturnType, + REC = object & ReturnType, +> +( + getRecord: G, + create: C, +) { + const creatorFunction = (id: any, context: RecordStore>) => { + watchEffect(() => { + if (!getRecord(id)) { + context.remove(id) + } + }) + + const record = getRecord(id) + if (!record) { + throw new Error(`watchRecordStore(): Record id "${id}" not found.`) + } + return create(record as REC, context) + } + + return defineRecordStore(creatorFunction as C) +} \ No newline at end of file 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.test.ts b/tests/defineCacheStore.test.ts index a825eb1..041832c 100644 --- a/tests/defineCacheStore.test.ts +++ b/tests/defineCacheStore.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { defineCacheStore } from '../src' +import { defineRecordStore } from '../src' import { mount } from '@vue/test-utils' import { computed, nextTick, reactive, ref, toRef, toValue, watch } from 'vue' @@ -7,7 +7,7 @@ describe('define cache store', async () => { it('ref()', async () => { const x = ref('a') - const useTestCache = defineCacheStore((id) => { + const cache = defineRecordStore((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 = defineRecordStore((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 = defineRecordStore((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 = defineRecordStore((id) => { const item = findItem(id) as Item return { id: ref(id), @@ -277,8 +263,6 @@ describe('define cache store', async () => { }, setup(props: { cacheId: Number }) { 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({ @@ -377,7 +361,7 @@ describe('define cache store', async () => { it('only caches once', async () => { const count = ref(0) - const useTestCache = defineCacheStore((id) => { + const cache = defineRecordStore((id) => { count.value++ return { count: computed(() => count), @@ -386,7 +370,6 @@ describe('define cache store', async () => { const App = { setup() { - const cache = useTestCache() const { count } = cache.getRefs(99) return { @@ -400,11 +383,8 @@ describe('define cache store', async () => { 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') }) @@ -416,7 +396,7 @@ describe('define cache store', async () => { C: { name: 'Susan' }, } - const useTestCache = defineCacheStore((id: string, { get }) => { + const cache = defineRecordStore((id: string, { get }) => { return { id: computed(() => id), @@ -438,7 +418,6 @@ describe('define cache store', async () => { itemId: String, }, setup(props: any) { - const cache = useTestCache() const { id, name, friend } = cache.getRefs(props.itemId) @@ -469,7 +448,7 @@ describe('define cache store', async () => { }) }) - it('remove other cached values', async () => { + it('remove() other cached values', async () => { type Item = { id: string; name: string; @@ -485,7 +464,7 @@ describe('define cache store', async () => { }, ]) - const useTestCache = defineCacheStore((id: string, { get, remove }) => { + const cache = defineRecordStore((id: string, { get, remove }) => { watch(data, (newValue) => { const exists = newValue.find((item) => item.id === id) @@ -502,9 +481,7 @@ describe('define cache store', async () => { return exists?.name }), } - }, { autoMountAndUnMount: false, autoClearUnused: false }) - - const cache = useTestCache() + }) expect(cache.get('A')).toEqual({ id: 'A', @@ -528,31 +505,24 @@ describe('define cache store', async () => { }) it('can use has() and getUseCount() internally', async () => { - const useTestCache = defineCacheStore((id: string, { has, getUseCount }) => { + const cache = defineRecordStore((id: string, { has }) => { 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/defineCacheStore.types.test.ts b/tests/defineCacheStore.types.test.ts index 61a7fe9..84730ea 100644 --- a/tests/defineCacheStore.types.test.ts +++ b/tests/defineCacheStore.types.test.ts @@ -1,13 +1,6 @@ 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' +import { defineRecordStore, type GenericRecordStore, type RecordStore } from '../src' type Item = { id: number, @@ -22,53 +15,19 @@ describe('defineCacheStore() types', async () => { 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 => { + const cache: CustomCacheStore = defineRecordStore((id: number, context: RecordStore): 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 { @@ -77,28 +36,25 @@ describe('defineCacheStore() types', async () => { } } - const useTestCache = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - const store: GenericCacheStore = useTestCache() - expectTypeOf(store).toEqualTypeOf() + const store: GenericRecordStore = defineRecordStore(creatorFunction) + expectTypeOf(store).toEqualTypeOf() }) it('type CacheStore', () => { let called = 0 - function creatorFunction(id: number, context: CacheStore) { + function creatorFunction(id: number, context: RecordStore) { called++ - expectTypeOf(context).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf>() return { id, name: 'susan', } } - const useTestCache = defineCacheStore(creatorFunction, { autoMountAndUnMount: false }) - - const cache = useTestCache() + const cache = defineRecordStore(creatorFunction) - expectTypeOf(cache).toEqualTypeOf>() + expectTypeOf(cache).toEqualTypeOf>() cache.get('asd') expect(called).toEqual(1) diff --git a/tests/defineRecordStore.test.ts b/tests/defineRecordStore.examples.test.ts similarity index 93% rename from tests/defineRecordStore.test.ts rename to tests/defineRecordStore.examples.test.ts index a260b55..e29b0d8 100644 --- a/tests/defineRecordStore.test.ts +++ b/tests/defineRecordStore.examples.test.ts @@ -1,20 +1,11 @@ import { describe, expect, it } from 'vitest' -import { defineRecordStore, watchRecordStore } from '../src' +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('default options', async () => { - expect( - defineRecordStore.getGlobalDefaultOptions(), - ).toEqual({ - autoMountAndUnMount: false, - autoClearUnused: false, - }) - }) - it('test readme example', async () => { type Person = { id: number, @@ -34,7 +25,7 @@ describe('pinia integration', async () => { } } - const usePersonInfo = defineRecordStore( + const personInfo = watchRecordStore( (id: number) => getPerson(id), (record: Person) => { const { id: personId, name } = toRefs(record) @@ -47,8 +38,6 @@ describe('pinia integration', async () => { }, ) - const personInfo = usePersonInfo() - const person = personInfo.get(99) expect(person.name).toBe('Jim') @@ -249,5 +238,5 @@ async function test_store(store: ExtendedPeopleStore) { 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.`) + expect(() => store.getInfo(id)).toThrowError(`watchRecordStore(): 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..0d68090 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/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() -}) From 229f3efce299d542aa470f51f8dd4c0fded5ffaf Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Thu, 31 Jul 2025 21:25:36 -0400 Subject: [PATCH 2/9] initial v2 --- src/defineRecordStore.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/defineRecordStore.ts b/src/defineRecordStore.ts index 87f70a7..cdc10f4 100644 --- a/src/defineRecordStore.ts +++ b/src/defineRecordStore.ts @@ -19,31 +19,32 @@ export interface RecordStore { export type GenericRecordStore = ReturnType export function defineRecordStore< - C extends (id: any, context: RecordStore>) => ReturnType + O extends object, + C extends (id: any, context: RecordStore>) => O >(creatorFunction: C) { - type RecordStoreResult = RecordStore> + type Result = Reactive> - const cache = new Map>>() + const cache = new Map() - function get(id: any): Reactive> { + function get(id: any): Result { let result = cache.get(id) if (result) { return result } - const object = creatorFunction(id, context) as object - result = reactive(object) as Reactive> + const object = creatorFunction(id, context) + result = reactive(object) as Result cache.set(id, result) return result } const getRefs = (id: any) => { - const obj = get(id) as Reactive> + const obj = get(id) as Result return reactiveToRefs(obj) } - const context: RecordStoreResult = { + const context: RecordStore> = { ids: () => [...cache.keys()], get, getRefs, @@ -53,4 +54,12 @@ export function defineRecordStore< } return context -} \ No newline at end of file +} + +const c = defineRecordStore(() => { + return { + foo: 'bar', + } +}) + +const z = c.get(99) \ No newline at end of file From cec8c84d1b6aa007eb49d1a8a4cd2dc0a322c44b Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Thu, 31 Jul 2025 23:00:56 -0400 Subject: [PATCH 3/9] initial v2 --- src/defineRecordStore.ts | 50 +++++++++++++--------------- src/reactiveToRefs.ts | 2 +- src/watchRecordStore.ts | 18 +++++----- tests/defineCacheStore.test.ts | 7 ++-- tests/defineCacheStore.types.test.ts | 8 ++--- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/defineRecordStore.ts b/src/defineRecordStore.ts index cdc10f4..2e15997 100644 --- a/src/defineRecordStore.ts +++ b/src/defineRecordStore.ts @@ -1,65 +1,63 @@ import { type Reactive, reactive, type ToRefs } from 'vue' import { reactiveToRefs } from './reactiveToRefs' -export interface RecordStore { +export type RecordStore> = { // get cached ids ids(): any[], // get reactive object - get(id: any): Reactive, + get(id: ID): Reactive, // get refs wrapped object like pinia's storeToRefs(useMyStore()) - getRefs(id: any): ToRefs>, + getRefs(id: ID): ToRefs>, // check if id is cached - has(id: any): boolean, + has(id: ID): boolean, // remove cached id - remove(id: any): void, + remove(id: ID): void, // clear all cache ids clear(): void, + // loop over each cached item + forEach(callbackFunction: (value: Reactive, key: ID, map: Map>) => void, thisArg?: any): void; } -export type GenericRecordStore = ReturnType +export type GenericRecordStore<> = ReturnType export function defineRecordStore< - O extends object, - C extends (id: any, context: RecordStore>) => O + C extends (id: any, context: RecordStore, Parameters[0]>) => object & ReturnType, >(creatorFunction: C) { - type Result = Reactive> + type ID = Parameters[0] + type Result = object & ReturnType + type ReactiveResult = Reactive - const cache = new Map() + const cache = new Map() - function get(id: any): Result { + const get = (id: ID): ReactiveResult => { let result = cache.get(id) if (result) { return result } const object = creatorFunction(id, context) - result = reactive(object) as Result + result = reactive(object) as ReactiveResult cache.set(id, result) return result } - const getRefs = (id: any) => { - const obj = get(id) as Result + const getRefs = (id: ID) => { + const obj = get(id) as ReactiveResult return reactiveToRefs(obj) } - const context: RecordStore> = { + const context: RecordStore = { ids: () => [...cache.keys()], get, getRefs, - has: (id: any) => cache.has(id), - remove: (id: any) => cache.delete(id), + has: (id: ID) => cache.has(id), + remove: (id: ID) => cache.delete(id), clear: () => cache.clear(), + forEach: (callbackFunction: (value: ReactiveResult, key: ID, map: Map) => void, thisArg?: any) => { + cache.forEach(callbackFunction, thisArg) + } } return context -} - -const c = defineRecordStore(() => { - return { - foo: 'bar', - } -}) - -const z = c.get(99) \ No newline at end of file +} \ 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/watchRecordStore.ts b/src/watchRecordStore.ts index 6f93be8..3131012 100644 --- a/src/watchRecordStore.ts +++ b/src/watchRecordStore.ts @@ -2,15 +2,17 @@ import { watchEffect } from 'vue' import { defineRecordStore, type RecordStore } from './defineRecordStore' export function watchRecordStore< - C extends (record: REC, context: RecordStore>) => ReturnType, - G extends (id: any) => ReturnType, - REC = object & ReturnType, + G extends (id: any) => object & ReturnType | undefined, + C2 extends (record: object & NonNullable>, context: RecordStore, Parameters[0]>) => object & ReturnType, > ( getRecord: G, - create: C, + create: C2, ) { - const creatorFunction = (id: any, context: RecordStore>) => { + type ID = Parameters[0] + type Result = RecordStore, ID> + + const creatorFunction = (id: ID, context: Result) => { watchEffect(() => { if (!getRecord(id)) { context.remove(id) @@ -21,8 +23,8 @@ export function watchRecordStore< if (!record) { throw new Error(`watchRecordStore(): Record id "${id}" not found.`) } - return create(record as REC, context) + return create(record, context) } - return defineRecordStore(creatorFunction as C) -} \ No newline at end of file + return defineRecordStore(creatorFunction) +} diff --git a/tests/defineCacheStore.test.ts b/tests/defineCacheStore.test.ts index 041832c..f4122aa 100644 --- a/tests/defineCacheStore.test.ts +++ b/tests/defineCacheStore.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { defineRecordStore } from '../src' import { mount } from '@vue/test-utils' import { computed, nextTick, reactive, ref, toRef, toValue, watch } from 'vue' +import type { RecordStore } from '../types' describe('define cache store', async () => { @@ -249,7 +250,7 @@ describe('define cache store', async () => { return data.value.find((item) => item.id === id) as Item } - const cache = defineRecordStore((id) => { + const cache = defineRecordStore((id: string) => { const item = findItem(id) as Item return { id: ref(id), @@ -261,7 +262,7 @@ describe('define cache store', async () => { props: { cacheId: String, }, - setup(props: { cacheId: Number }) { + setup(props: { cacheId: string }) { const cacheId = computed(() => props.cacheId) const comp = computed(() => cache.get(cacheId.value)) const compRefs = computed(() => cache.getRefs(cacheId.value)) @@ -396,7 +397,7 @@ describe('define cache store', async () => { C: { name: 'Susan' }, } - const cache = defineRecordStore((id: string, { get }) => { + const cache = defineRecordStore((id: string, { get }: RecordStore) => { return { id: computed(() => id), diff --git a/tests/defineCacheStore.types.test.ts b/tests/defineCacheStore.types.test.ts index 84730ea..5d9a4b0 100644 --- a/tests/defineCacheStore.types.test.ts +++ b/tests/defineCacheStore.types.test.ts @@ -43,9 +43,9 @@ describe('defineCacheStore() types', async () => { it('type CacheStore', () => { let called = 0 - function creatorFunction(id: number, context: RecordStore) { + function creatorFunction(id: number, context: RecordStore) { called++ - expectTypeOf(context).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf>() return { id, name: 'susan', @@ -54,9 +54,9 @@ describe('defineCacheStore() types', async () => { const cache = defineRecordStore(creatorFunction) - expectTypeOf(cache).toEqualTypeOf>() + expectTypeOf(cache).toEqualTypeOf>() - cache.get('asd') + cache.get(99) expect(called).toEqual(1) }) }) \ No newline at end of file From 47d57d718aa987e72546524c8e64da9fb47b8a82 Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Fri, 1 Aug 2025 10:43:47 -0400 Subject: [PATCH 4/9] progress --- README.md | 77 ++++---- src/index.ts | 4 +- ...efineRecordStore.ts => makeRecordStore.ts} | 12 +- src/watchRecordStore.ts | 4 +- ...t.ts => makeRecordStore.component.test.ts} | 176 +---------------- tests/makeRecordStore.test.ts | 182 ++++++++++++++++++ ....test.ts => makeRecordStore.types.test.ts} | 8 +- ...t.ts => watchRecordStore.examples.test.ts} | 0 8 files changed, 245 insertions(+), 218 deletions(-) rename src/{defineRecordStore.ts => makeRecordStore.ts} (77%) rename tests/{defineCacheStore.test.ts => makeRecordStore.component.test.ts} (69%) create mode 100644 tests/makeRecordStore.test.ts rename tests/{defineCacheStore.types.test.ts => makeRecordStore.types.test.ts} (79%) rename tests/{defineRecordStore.examples.test.ts => watchRecordStore.examples.test.ts} (100%) diff --git a/README.md b/README.md index c5d5f45..46c8e2d 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ 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. +### Generic Vue example + ```ts // person-data.ts +import { computed, ref, reactive } from 'vue' + type Person = { id: number, name: string, @@ -23,25 +27,31 @@ export const people = ref([{ export const getPerson = (id: number) => people.value.find(person => person.id === id) export const getPersonInfo = (person: Person) => { + // 🧠 imagine this is non-trivial and complicated 🧠 const firstName = computed(() => person.firstName) const lastName = computed(() => person.lastName) - // 🧠 imagine this is non-trivial and complicated 🧠 - return { + const fullName = computed(() => firstName.value + ' ' + lastName.value) + + return reactive({ id: computed(() => id), firstName, lastName, - fullName: computed(() => firstName.value + ' ' + lastName.value) - } + fullName, + }) } +``` +```ts // in multiple components +import { getPerson, getPersonInfo } from 'person-data.ts' + const person = getPerson(99) const info = getPersonInfo(person) // each time getPersonInfo() is called // it is re-run and creates redundant copies of its info object ``` -### Solution +### Vue Cache Store Solution Reusable non-trivial computed/reactive objects in multiple components. ```ts @@ -58,6 +68,7 @@ export const personInfo = watchRecordStore( (person: Person) => getPersonInfo(person), ) ``` + ```ts // inside multiple components import { personInfo } from 'person-info.ts' @@ -74,12 +85,12 @@ const computedLastName = computed(() => personInfo.get(id).lastName) ## Usage -### Define a Cache Store +### Define a Record 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 { makeRecordStore } from 'vue-cache-store' import { computed } from 'vue' // simplified data source @@ -91,7 +102,7 @@ const people = ref([{ const getPerson = (id: number) => people.value.find(person => person.id === id) -export const usePersonCache = defineCacheStore((id) => { +export const usePersonCache = makeRecordStore((id) => { const person = getPerson(id) const firstName = computed(() => person.firstName) const lastName = computed(() => person.lastName) @@ -138,10 +149,10 @@ export const usePersonCache = defineCacheStore((id) => { ```ts // person-cache.ts -import { defineCacheStore } from 'vue-cache-store' +import { makeRecordStore } from 'vue-cache-store' import { type ToRefs, type Reactive} from 'vue' -export const usePersonCache = defineCacheStore((id: number): Item => { +export const usePersonCache = makeRecordStore((id: number): Item => { return { id, name: 'sue', @@ -188,11 +199,11 @@ The `context` argument is the current cache store instance. ```ts // person-cache.ts -import { defineCacheStore } from 'vue-cache-store' +import { makeRecordStore } from 'vue-cache-store' import { getRecordInfo } from 'record-info-getter' import { computed } from 'vue' -export const usePersonCache = defineCacheStore((id, context: CacheStore) => { +export const usePersonCache = makeRecordStore((id, context: CacheStore) => { const info = getRecordInfo(id) const firstName = computed(() => info.firstName) const lastName = computed(() => info.lastName) @@ -212,13 +223,13 @@ export const usePersonCache = defineCacheStore((id, context: CacheStore) => { Designed to cache an object store based on a record object. -#### `defineRecordStore()` -Internally calls and returns `defineCacheStore()` +#### `makeRecordStore()` +Internally calls and returns `makeRecordStore()` ```ts // person-info.ts import { computed, ref } from 'vue' -import { defineRecordStore } from 'vue-cache-store' +import { makeRecordStore } from 'vue-cache-store' // minimal example const people = ref([{ @@ -233,8 +244,8 @@ const removePerson = (id) => { people.value.splice(index, 1) } } -// defineRecordStore() internally calls and returns defineCacheStore() -export const usePersonInfo = defineRecordStore( +// makeRecordStore() internally calls and returns makeRecordStore() +export const usePersonInfo = makeRecordStore( // record watcher (id: number) => { // this function is watched @@ -293,7 +304,7 @@ import { watchRecordStore } from 'vue-cache-store' export const personInfo = watchRecordStore(/* ... */) // watchRecordStore() internally does the following: -const useInfo = defineRecordStore(/* ... */)() +const useInfo = makeRecordStore(/* ... */)() // with typing intact return useInfo() ``` @@ -404,7 +415,7 @@ When defining a cache store the second argument is a default options object. | `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 +#### `makeRecordStore()` Options ```ts // global default option values const options = { @@ -412,19 +423,19 @@ const options = { autoClearUnused: true, } ``` -#### `defineCacheStore()` Options Usage +#### `makeRecordStore()` Options Usage ```ts // person-cache.ts -import { defineCacheStore } from 'vue-cache-store' +import { makeRecordStore } from 'vue-cache-store' -// set new global defaults for all stores created with defineCacheStore() -defineCacheStore.setGlobalDefaultOptions({ +// set new global defaults for all stores created with makeRecordStore() +makeRecordStore.setGlobalDefaultOptions({ autoMountAndUnMount: false, autoClearUnused: false, }) // defining a cache store with store default options overriding global defaults -export const usePersonCache = defineCacheStore((id) => { +export const usePersonCache = makeRecordStore((id) => { return { // ... } @@ -434,14 +445,14 @@ export const usePersonCache = defineCacheStore((id) => { }) // inside a component -// overrides usePersonCache default options and defineCacheStore global defaults +// overrides usePersonCache default options and makeRecordStore global defaults const personCache = usePersonCache({ autoMountAndUnMount: true, autoClearUnused: false, }) ``` -#### `defineRecordStore()` Options +#### `makeRecordStore()` Options The intended use case for record stores removes cache objects when their source has been removed. So its global defaults are different. ```ts @@ -451,20 +462,20 @@ const options = { autoClearUnused: false, } ``` -#### `defineRecordStore()` Options Usage +#### `makeRecordStore()` Options Usage ```ts // person-record.ts -import { defineRecordStore } from 'vue-cache-store' +import { makeRecordStore } from 'vue-cache-store' -// set new global defaults for all stores created with defineRecordStore() -defineRecordStore.setGlobalDefaultOptions({ +// set new global defaults for all stores created with makeRecordStore() +makeRecordStore.setGlobalDefaultOptions({ autoMountAndUnMount: false, autoClearUnused: false, }) // defining a record store with store default options overriding global defaults -export const usePersonRecord = defineRecordStore( +export const usePersonRecord = makeRecordStore( (id) => { // ... }, @@ -478,14 +489,14 @@ export const usePersonRecord = defineRecordStore( ) // inside a component -// overrides usePersonRecord default options and defineRecordStore global defaults +// overrides usePersonRecord default options and makeRecordStore global defaults const personCache = usePersonRecord({ autoMountAndUnMount: true, autoClearUnused: false, }) ``` #### `watchRecordStore()` Options -`watchRecordStore()` calls `defineRecordStore()` internally so it uses the global default options `defineRecordStore()` +`watchRecordStore()` calls `makeRecordStore()` internally so it uses the global default options `makeRecordStore()` ```ts import { watchRecordStore } from 'vue-cache-store' diff --git a/src/index.ts b/src/index.ts index b20fec0..4ce6e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ export { - defineRecordStore, + makeRecordStore, type GenericRecordStore, type RecordStore, -} from './defineRecordStore' +} from './makeRecordStore' export { watchRecordStore, diff --git a/src/defineRecordStore.ts b/src/makeRecordStore.ts similarity index 77% rename from src/defineRecordStore.ts rename to src/makeRecordStore.ts index 2e15997..8a0c852 100644 --- a/src/defineRecordStore.ts +++ b/src/makeRecordStore.ts @@ -3,7 +3,7 @@ import { reactiveToRefs } from './reactiveToRefs' export type RecordStore> = { // get cached ids - ids(): any[], + ids(): ID[], // get reactive object get(id: ID): Reactive, // get refs wrapped object like pinia's storeToRefs(useMyStore()) @@ -15,12 +15,12 @@ export type RecordStore> = { // clear all cache ids clear(): void, // loop over each cached item - forEach(callbackFunction: (value: Reactive, key: ID, map: Map>) => void, thisArg?: any): void; + forEach(callbackFunction: (value: Reactive, key: ID) => void): void; } -export type GenericRecordStore<> = ReturnType +export type GenericRecordStore<> = ReturnType -export function defineRecordStore< +export function makeRecordStore< C extends (id: any, context: RecordStore, Parameters[0]>) => object & ReturnType, >(creatorFunction: C) { type ID = Parameters[0] @@ -54,8 +54,8 @@ export function defineRecordStore< has: (id: ID) => cache.has(id), remove: (id: ID) => cache.delete(id), clear: () => cache.clear(), - forEach: (callbackFunction: (value: ReactiveResult, key: ID, map: Map) => void, thisArg?: any) => { - cache.forEach(callbackFunction, thisArg) + forEach: (callbackFunction: (value: ReactiveResult, key: ID) => void) => { + cache.forEach(callbackFunction) } } diff --git a/src/watchRecordStore.ts b/src/watchRecordStore.ts index 3131012..8821c3f 100644 --- a/src/watchRecordStore.ts +++ b/src/watchRecordStore.ts @@ -1,5 +1,5 @@ import { watchEffect } from 'vue' -import { defineRecordStore, type RecordStore } from './defineRecordStore' +import { makeRecordStore, type RecordStore } from './makeRecordStore' export function watchRecordStore< G extends (id: any) => object & ReturnType | undefined, @@ -26,5 +26,5 @@ export function watchRecordStore< return create(record, context) } - return defineRecordStore(creatorFunction) + return makeRecordStore(creatorFunction) } diff --git a/tests/defineCacheStore.test.ts b/tests/makeRecordStore.component.test.ts similarity index 69% rename from tests/defineCacheStore.test.ts rename to tests/makeRecordStore.component.test.ts index f4122aa..ae41c8d 100644 --- a/tests/defineCacheStore.test.ts +++ b/tests/makeRecordStore.component.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { defineRecordStore } from '../src' +import { makeRecordStore } from '../src' import { mount } from '@vue/test-utils' import { computed, nextTick, reactive, ref, toRef, toValue, watch } from 'vue' import type { RecordStore } from '../types' @@ -8,7 +8,7 @@ describe('define cache store', async () => { it('ref()', async () => { const x = ref('a') - const cache = defineRecordStore((id) => { + const cache = makeRecordStore((id) => { return { id: ref(id), x, @@ -97,7 +97,7 @@ describe('define cache store', async () => { set: (value => x2.value = value), }) - const cache = defineRecordStore((id) => { + const cache = makeRecordStore((id) => { return { id: computed(() => id), c, @@ -178,7 +178,7 @@ describe('define cache store', async () => { it('reactive()', async () => { const r = reactive({ x: 'a' }) - const cache = defineRecordStore((id) => { + const cache = makeRecordStore((id) => { return { r, } @@ -250,7 +250,7 @@ describe('define cache store', async () => { return data.value.find((item) => item.id === id) as Item } - const cache = defineRecordStore((id: string) => { + const cache = makeRecordStore((id: string) => { const item = findItem(id) as Item return { id: ref(id), @@ -360,170 +360,4 @@ describe('define cache store', async () => { } }) - it('only caches once', async () => { - const count = ref(0) - const cache = defineRecordStore((id) => { - count.value++ - return { - count: computed(() => count), - } - }) - - const App = { - setup() { - const { count } = cache.getRefs(99) - - return { - cache, - count, - } - }, - template: `{{count}}`, - } - - const wrapper = mount(App, {}) - const wrapper2 = mount(App, {}) - - // @ts-expect-error - expect(wrapper2.vm.count.value).toBe(1) - 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 cache = defineRecordStore((id: string, { get }: RecordStore) => { - - 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 { 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 cache = defineRecordStore((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 - }), - } - }) - - 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 cache = defineRecordStore((id: string, { has }) => { - return { - id: computed(() => id), - hasA: computed(() => has('A')), - hasB: computed(() => has('B')), - } - }) - - expect(toValue(cache.get('A'))).toEqual({ - id: 'A', - hasA: true, - hasB: false, - }) - - expect(toValue(cache.get('B'))).toEqual({ - id: 'B', - hasA: true, - hasB: true, - }) - }) }) diff --git a/tests/makeRecordStore.test.ts b/tests/makeRecordStore.test.ts new file mode 100644 index 0000000..65140c9 --- /dev/null +++ b/tests/makeRecordStore.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { makeRecordStore, type RecordStore } from '../src' +import { computed, nextTick, ref, watch } from 'vue' + +describe('define cache store', async () => { + + it('only run once', async () => { + const count = ref(0) + const cache = makeRecordStore((id) => { + count.value++ + return { + count: computed(() => count), + } + }) + + expect(cache.get(99).count.value).toBe(1) + expect(cache.ids()).toEqual([99]) + }) + + it('get other cached values', async () => { + + type Item = { + name: string + } + + const data: { [K: string]: { name: string } } = { + A: { name: 'Jim' }, + B: { name: 'Lisa' }, + C: { name: 'Susan' }, + } + + const cache = makeRecordStore((id: string, { get }: RecordStore) => { + 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 () => { + 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/defineCacheStore.types.test.ts b/tests/makeRecordStore.types.test.ts similarity index 79% rename from tests/defineCacheStore.types.test.ts rename to tests/makeRecordStore.types.test.ts index 5d9a4b0..914cd9a 100644 --- a/tests/defineCacheStore.types.test.ts +++ b/tests/makeRecordStore.types.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import type { Reactive, ToRefs } from 'vue' -import { defineRecordStore, type GenericRecordStore, type RecordStore } from '../src' +import { makeRecordStore, type GenericRecordStore, type RecordStore } from '../src' type Item = { id: number, @@ -18,7 +18,7 @@ describe('defineCacheStore() types', async () => { clear(): void, } - const cache: CustomCacheStore = defineRecordStore((id: number, context: RecordStore): Item => { + const cache: CustomCacheStore = makeRecordStore((id: number, context: RecordStore): Item => { return { id, name: 'susan', @@ -36,7 +36,7 @@ describe('defineCacheStore() types', async () => { } } - const store: GenericRecordStore = defineRecordStore(creatorFunction) + const store: GenericRecordStore = makeRecordStore(creatorFunction) expectTypeOf(store).toEqualTypeOf() }) @@ -52,7 +52,7 @@ describe('defineCacheStore() types', async () => { } } - const cache = defineRecordStore(creatorFunction) + const cache = makeRecordStore(creatorFunction) expectTypeOf(cache).toEqualTypeOf>() diff --git a/tests/defineRecordStore.examples.test.ts b/tests/watchRecordStore.examples.test.ts similarity index 100% rename from tests/defineRecordStore.examples.test.ts rename to tests/watchRecordStore.examples.test.ts From 007af876754543044c5b967be3af833ccb5e6372 Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Fri, 1 Aug 2025 12:58:33 -0400 Subject: [PATCH 5/9] progress --- src/makeRecordStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/makeRecordStore.ts b/src/makeRecordStore.ts index 8a0c852..e8baa75 100644 --- a/src/makeRecordStore.ts +++ b/src/makeRecordStore.ts @@ -18,7 +18,7 @@ export type RecordStore> = { forEach(callbackFunction: (value: Reactive, key: ID) => void): void; } -export type GenericRecordStore<> = ReturnType +export type GenericRecordStore = ReturnType export function makeRecordStore< C extends (id: any, context: RecordStore, Parameters[0]>) => object & ReturnType, From b1223b64f77b6a4fb65a6c050b9f57618725103a Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Fri, 1 Aug 2025 12:58:41 -0400 Subject: [PATCH 6/9] progress --- tests/makeRecordStore.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/makeRecordStore.test.ts b/tests/makeRecordStore.test.ts index 65140c9..79bc3c8 100644 --- a/tests/makeRecordStore.test.ts +++ b/tests/makeRecordStore.test.ts @@ -6,7 +6,7 @@ describe('define cache store', async () => { it('only run once', async () => { const count = ref(0) - const cache = makeRecordStore((id) => { + const cache = makeRecordStore((id: number) => { count.value++ return { count: computed(() => count), @@ -15,6 +15,9 @@ describe('define cache store', async () => { 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 () => { From 4a96ca4611d76c34196cc5858f2b2dd486525fdb Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Fri, 1 Aug 2025 13:51:27 -0400 Subject: [PATCH 7/9] progress --- src/makeRecordStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/makeRecordStore.ts b/src/makeRecordStore.ts index e8baa75..cd65282 100644 --- a/src/makeRecordStore.ts +++ b/src/makeRecordStore.ts @@ -21,9 +21,9 @@ export type RecordStore> = { export type GenericRecordStore = ReturnType export function makeRecordStore< - C extends (id: any, context: RecordStore, Parameters[0]>) => object & ReturnType, + ID extends NonNullable, + C extends (id: ID, context: RecordStore, ID>) => object & ReturnType, >(creatorFunction: C) { - type ID = Parameters[0] type Result = object & ReturnType type ReactiveResult = Reactive From 3a9c6595c4b2086748c7313d56f102147a6c6f15 Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Fri, 1 Aug 2025 14:29:38 -0400 Subject: [PATCH 8/9] progress --- src/makeRecordStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/makeRecordStore.ts b/src/makeRecordStore.ts index cd65282..51ec415 100644 --- a/src/makeRecordStore.ts +++ b/src/makeRecordStore.ts @@ -21,9 +21,9 @@ export type RecordStore> = { export type GenericRecordStore = ReturnType export function makeRecordStore< - ID extends NonNullable, - C extends (id: ID, context: RecordStore, ID>) => object & ReturnType, + C extends (id: NonNullable, context: RecordStore, Parameters[0]>) => object & ReturnType, >(creatorFunction: C) { + type ID = Parameters[0] type Result = object & ReturnType type ReactiveResult = Reactive From fae35cff1f0c03bf6b931cd13c51f734680c92fc Mon Sep 17 00:00:00 2001 From: Carl Olsen Date: Sat, 2 Aug 2025 13:38:37 -0400 Subject: [PATCH 9/9] progress --- README.md | 457 +++++++----------------- package.json | 4 +- src/makeRecordStore.ts | 13 +- src/watchRecordStore.ts | 24 +- tests/helpers/people.ts | 2 +- tests/makeRecordStore.component.test.ts | 3 +- tests/makeRecordStore.test.ts | 21 +- tests/makeRecordStore.types.test.ts | 106 ++++-- tests/readme-examples.test.ts | 195 ++++++++++ tests/watchRecordStore.examples.test.ts | 151 -------- 10 files changed, 434 insertions(+), 542 deletions(-) create mode 100644 tests/readme-examples.test.ts diff --git a/README.md b/README.md index 46c8e2d..18be47f 100644 --- a/README.md +++ b/README.md @@ -8,285 +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. -### Generic Vue example - +⭐️ Examples in this readme reference this case when it has `import { /* ... */ } from 'person-data.ts'` ```ts // person-data.ts -import { computed, ref, reactive } from 'vue' +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) => { - // 🧠 imagine this is non-trivial and complicated 🧠 - const firstName = computed(() => person.firstName) - const lastName = computed(() => person.lastName) - const fullName = computed(() => firstName.value + ' ' + lastName.value) +export const getPersonInfo = (person: Person): PersonInfo => { + const { firstName, lastName } = toRefs(person) - return reactive({ - id: computed(() => id), + // 🧠 imagine this is non-trivial and complicated 🧠 + return { + id: computed(() => person.id), firstName, lastName, - fullName, - }) + fullName: computed(() => firstName.value + ' ' + lastName.value), + } } ``` -```ts -// in multiple components -import { getPerson, getPersonInfo } from 'person-data.ts' +### Generic Example + +```vue +// inside multiple vue components + + ``` ### Vue Cache Store Solution -Reusable non-trivial computed/reactive objects in multiple components. ```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 Record 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 { makeRecordStore } 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 = makeRecordStore((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 { makeRecordStore } from 'vue-cache-store' -import { type ToRefs, type Reactive} from 'vue' +## How It Works -export const usePersonCache = makeRecordStore((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 +// person-info.ts import { makeRecordStore } from 'vue-cache-store' -import { getRecordInfo } from 'record-info-getter' -import { computed } from 'vue' - -export const usePersonCache = makeRecordStore((id, context: CacheStore) => { - const info = getRecordInfo(id) - const firstName = computed(() => info.firstName) - const lastName = computed(() => info.lastName) - const manager = context.get(info.managerId) - + +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. - -#### `makeRecordStore()` -Internally calls and returns `makeRecordStore()` +### Watch Record Stores `watchRecordStore()` ```ts // person-info.ts -import { computed, ref } from 'vue' -import { makeRecordStore } 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) - } -} -// makeRecordStore() internally calls and returns makeRecordStore() -export const usePersonInfo = makeRecordStore( - // 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) @@ -297,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 = makeRecordStore(/* ... */)() -// with typing intact -return useInfo() -``` - #### Usage within a [Pinia](https://pinia.vuejs.org/) store ```ts @@ -316,7 +236,6 @@ return useInfo() import { defineStore } from 'pinia' import { watchRecordStore } from 'vue-cache-store' -// minimal example type Person = { id: number, name: string, @@ -354,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), } @@ -406,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. | - -#### `makeRecordStore()` Options -```ts -// global default option values -const options = { - autoMountAndUnMount: true, - autoClearUnused: true, -} -``` -#### `makeRecordStore()` Options Usage -```ts -// person-cache.ts -import { makeRecordStore } from 'vue-cache-store' - -// set new global defaults for all stores created with makeRecordStore() -makeRecordStore.setGlobalDefaultOptions({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// defining a cache store with store default options overriding global defaults -export const usePersonCache = makeRecordStore((id) => { - return { - // ... - } -}, { - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// inside a component -// overrides usePersonCache default options and makeRecordStore global defaults -const personCache = usePersonCache({ - autoMountAndUnMount: true, - autoClearUnused: false, -}) -``` - -#### `makeRecordStore()` 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, -} -``` -#### `makeRecordStore()` Options Usage - -```ts -// person-record.ts -import { makeRecordStore } from 'vue-cache-store' - -// set new global defaults for all stores created with makeRecordStore() -makeRecordStore.setGlobalDefaultOptions({ - autoMountAndUnMount: false, - autoClearUnused: false, -}) - -// defining a record store with store default options overriding global defaults -export const usePersonRecord = makeRecordStore( - (id) => { - // ... - }, - () => { - // ... - }, - { - autoMountAndUnMount: false, - autoClearUnused: false, - }, -) - -// inside a component -// overrides usePersonRecord default options and makeRecordStore global defaults -const personCache = usePersonRecord({ - autoMountAndUnMount: true, - autoClearUnused: false, -}) -``` -#### `watchRecordStore()` Options -`watchRecordStore()` calls `makeRecordStore()` internally so it uses the global default options `makeRecordStore()` -```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/makeRecordStore.ts b/src/makeRecordStore.ts index 51ec415..48d74ad 100644 --- a/src/makeRecordStore.ts +++ b/src/makeRecordStore.ts @@ -1,7 +1,7 @@ import { type Reactive, reactive, type ToRefs } from 'vue' import { reactiveToRefs } from './reactiveToRefs' -export type RecordStore> = { +export type RecordStore, T extends object> = { // get cached ids ids(): ID[], // get reactive object @@ -21,11 +21,10 @@ export type RecordStore> = { export type GenericRecordStore = ReturnType export function makeRecordStore< - C extends (id: NonNullable, context: RecordStore, Parameters[0]>) => object & ReturnType, ->(creatorFunction: C) { - type ID = Parameters[0] - type Result = object & ReturnType - type ReactiveResult = Reactive + ID extends NonNullable, + T extends object, +>(creatorFunction: (id: ID, context: RecordStore) => T) { + type ReactiveResult = Reactive const cache = new Map() @@ -47,7 +46,7 @@ export function makeRecordStore< return reactiveToRefs(obj) } - const context: RecordStore = { + const context: RecordStore = { ids: () => [...cache.keys()], get, getRefs, diff --git a/src/watchRecordStore.ts b/src/watchRecordStore.ts index 8821c3f..222f62b 100644 --- a/src/watchRecordStore.ts +++ b/src/watchRecordStore.ts @@ -1,30 +1,32 @@ -import { watchEffect } from 'vue' +import { computed, watch } from 'vue' import { makeRecordStore, type RecordStore } from './makeRecordStore' export function watchRecordStore< - G extends (id: any) => object & ReturnType | undefined, - C2 extends (record: object & NonNullable>, context: RecordStore, Parameters[0]>) => object & ReturnType, + ID extends NonNullable = Parameters[0]>[0], + R extends object = NonNullable[0]>>, + T extends object = ReturnType[1]>, > ( - getRecord: G, - create: C2, + getRecord: (id: ID) => R | undefined, + create: (record: R, context: RecordStore) => T, ) { - type ID = Parameters[0] - type Result = RecordStore, ID> + type Result = RecordStore const creatorFunction = (id: ID, context: Result) => { - watchEffect(() => { - if (!getRecord(id)) { + + const comp = computed(() => getRecord(id)) + watch(comp, () => { + if (!comp.value) { context.remove(id) } }) - const record = getRecord(id) + const record = comp.value if (!record) { throw new Error(`watchRecordStore(): Record id "${id}" not found.`) } return create(record, context) } - return makeRecordStore(creatorFunction) + return makeRecordStore(creatorFunction) } diff --git a/tests/helpers/people.ts b/tests/helpers/people.ts index 0d68090..9255592 100644 --- a/tests/helpers/people.ts +++ b/tests/helpers/people.ts @@ -15,7 +15,7 @@ export type PeopleStore = ReturnType export type ExtendedPeopleStore = Store PersonInfo, - personInfo: RecordStore + personInfo: RecordStore }> export const usePeople = () => { const people = ref([]) diff --git a/tests/makeRecordStore.component.test.ts b/tests/makeRecordStore.component.test.ts index ae41c8d..8ee18a4 100644 --- a/tests/makeRecordStore.component.test.ts +++ b/tests/makeRecordStore.component.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { makeRecordStore } from '../src' import { mount } from '@vue/test-utils' -import { computed, nextTick, reactive, ref, toRef, toValue, watch } from 'vue' -import type { RecordStore } from '../types' +import { computed, reactive, ref, toRef } from 'vue' describe('define cache store', async () => { diff --git a/tests/makeRecordStore.test.ts b/tests/makeRecordStore.test.ts index 79bc3c8..e55cda0 100644 --- a/tests/makeRecordStore.test.ts +++ b/tests/makeRecordStore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { makeRecordStore, type RecordStore } from '../src' -import { computed, nextTick, ref, watch } from 'vue' +import { makeRecordStore } from '../src' +import { computed, type ComputedRef, nextTick, ref, watch } from 'vue' describe('define cache store', async () => { @@ -21,18 +21,13 @@ describe('define cache store', async () => { }) it('get other cached values', async () => { - - type Item = { - name: string - } - const data: { [K: string]: { name: string } } = { A: { name: 'Jim' }, B: { name: 'Lisa' }, C: { name: 'Susan' }, } - const cache = makeRecordStore((id: string, { get }: RecordStore) => { + const cache = makeRecordStore((id: string, { get }) => { return { id: computed(() => id), name: computed(() => data[id].name), @@ -127,7 +122,14 @@ describe('define cache store', async () => { }) it('can use has() ids() forEach()', async () => { - const cache = makeRecordStore((id: string, { has, ids }) => { + type Item = { + id: ComputedRef, + ids: ComputedRef, + hasA: ComputedRef, + hasB: ComputedRef, + } + + const cache = makeRecordStore((id: string, { has, ids }) => { return { id: computed(() => id), ids: computed(() => ids()), @@ -163,7 +165,6 @@ describe('define cache store', async () => { }, ]) - const itemARefs = cache.getRefs('A') expect(itemARefs.id.value).toEqual('A') expect(itemARefs.ids.value).toEqual(['A']) diff --git a/tests/makeRecordStore.types.test.ts b/tests/makeRecordStore.types.test.ts index 914cd9a..04adac3 100644 --- a/tests/makeRecordStore.types.test.ts +++ b/tests/makeRecordStore.types.test.ts @@ -1,62 +1,100 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' +import { describe, expectTypeOf, it } from 'vitest' import type { Reactive, ToRefs } from 'vue' -import { makeRecordStore, type GenericRecordStore, type RecordStore } from '../src' - -type Item = { - id: number, - name: string, -} +import { type GenericRecordStore, makeRecordStore, type RecordStore } from '../src' 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, - clear(): void, - } - const cache: CustomCacheStore = makeRecordStore((id: number, context: RecordStore): Item => { + it('type GenericCacheStore', async () => { + function creatorFunction(id: number) { return { id, name: 'susan', } - }) + } - expectTypeOf(cache).toEqualTypeOf() + const store: GenericRecordStore = makeRecordStore(creatorFunction) + expectTypeOf(store).toEqualTypeOf() }) - it('type GenericCacheStore', async () => { - function creatorFunction(id: number) { + 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() - const store: GenericRecordStore = makeRecordStore(creatorFunction) - expectTypeOf(store).toEqualTypeOf() }) - it('type CacheStore', () => { - let called = 0 + it('makeRecordStore() types: id string', () => { + + type ItemInfo = { + id: string, + name: string, + context: RecordStore + } - function creatorFunction(id: number, context: RecordStore) { - called++ - expectTypeOf(context).toEqualTypeOf>() + const cache = makeRecordStore((id, context) => { return { id, name: 'susan', + context, } - } + }) - const cache = makeRecordStore(creatorFunction) + 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() - expectTypeOf(cache).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() - cache.get(99) - expect(called).toEqual(1) }) }) \ 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/watchRecordStore.examples.test.ts b/tests/watchRecordStore.examples.test.ts index e29b0d8..6b9c13d 100644 --- a/tests/watchRecordStore.examples.test.ts +++ b/tests/watchRecordStore.examples.test.ts @@ -6,157 +6,6 @@ import { type ExtendedPeopleStore, type Person, usePeople } from './helpers/peop describe('pinia integration', async () => { - 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 personInfo = watchRecordStore( - (id: number) => getPerson(id), - (record: Person) => { - const { id: personId, name } = toRefs(record) - - return { - id: personId, - name, - nameLength: computed(() => record.name.length || 0), - } - }, - ) - - 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', () => {