diff --git a/docs/storage.md b/docs/storage.md index 5eb10a383..9c9944a55 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -72,6 +72,9 @@ await storage.watch( }, ); await storage.getMeta<{ v: number }>('local:installDate'); +await storage.watchMeta<{ v: number }>('local:installDate', (newMeta) => { + // ... +}); ``` > This approach is fine for one-off storage fields or generic helpers, but [defining storage items](#defining-storage-items) is the recommended way to add type-safety. @@ -117,6 +120,17 @@ await storage.setMeta('local:preference', { v: 2 }); await storage.getMeta('local:preference'); // { v: 2, lastModified: 1703690746007 } ``` +To listen for metadata changes, use `watchMeta`: + +```ts +const unwatch = storage.watchMeta<{ lastModified: number }>( + 'local:preference', + (newMeta, oldMeta) => { + console.log('Preference metadata changed:', { newMeta, oldMeta }); + }, +); +``` + You can remove all metadata associated with a key, or just specific properties: ```ts @@ -155,6 +169,9 @@ await showChangelogOnUpdate.removeValue(); const unwatch = showChangelogOnUpdate.watch((newValue) => { // ... }); +const unwatchMeta = showChangelogOnUpdate.watchMeta((newMeta) => { + // ... +}); ``` ### Versioning diff --git a/packages/storage/src/__tests__/index.test.ts b/packages/storage/src/__tests__/index.test.ts index 3ad349724..faf3af82d 100644 --- a/packages/storage/src/__tests__/index.test.ts +++ b/packages/storage/src/__tests__/index.test.ts @@ -646,6 +646,63 @@ describe('Storage Utils', () => { }); }); + describe('watchMeta', () => { + it('should not trigger if the changed key is different from the metadata key', async () => { + const cb = vi.fn(); + + storage.watchMeta(`${storageArea}:key`, cb); + await storage.setMeta(`${storageArea}:not-the-key`, { v: 2 }); + + expect(cb).not.toBeCalled(); + }); + + it("should not trigger if the metadata doesn't change", async () => { + const cb = vi.fn(); + const meta = { syncedAt: 123 }; + + await storage.setMeta(`${storageArea}:key`, meta); + storage.watchMeta(`${storageArea}:key`, cb); + await storage.setMeta(`${storageArea}:key`, meta); + + expect(cb).not.toBeCalled(); + }); + + it('should call the callback when metadata changes', async () => { + const cb = vi.fn(); + const oldMeta = { syncedAt: 123 }; + const newMeta = { syncedAt: 456 }; + + await storage.setMeta(`${storageArea}:key`, oldMeta); + storage.watchMeta(`${storageArea}:key`, cb); + await storage.setMeta(`${storageArea}:key`, newMeta); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith(newMeta, oldMeta); + }); + + it('should use an empty object when metadata is removed', async () => { + const cb = vi.fn(); + const oldMeta = { syncedAt: 123 }; + + await storage.setMeta(`${storageArea}:key`, oldMeta); + storage.watchMeta(`${storageArea}:key`, cb); + await storage.removeMeta(`${storageArea}:key`); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith({}, oldMeta); + }); + + it('should remove the listener when calling the returned function', async () => { + const cb = vi.fn(); + + const unwatch = storage.watchMeta(`${storageArea}:key`, cb); + unwatch(); + await storage.setMeta(`${storageArea}:key`, { syncedAt: 123 }); + + expect(cb).not.toBeCalled(); + }); + }); + describe('unwatch', () => { it('should remove all watch listeners', async () => { const cb = vi.fn(); @@ -1295,6 +1352,74 @@ describe('Storage Utils', () => { }); }); + describe('watchMeta', () => { + it("should not trigger if the changed key is different from the item's metadata key", async () => { + const item = storage.defineItem(`local:key`); + const cb = vi.fn(); + + item.watchMeta(cb); + await storage.setMeta(`local:not-the-key`, { v: 2 }); + + expect(cb).not.toBeCalled(); + }); + + it("should not trigger if the metadata doesn't change", async () => { + const item = storage.defineItem( + `local:key`, + ); + const cb = vi.fn(); + const meta = { syncedAt: 123 }; + + await item.setMeta(meta); + item.watchMeta(cb); + await item.setMeta(meta); + + expect(cb).not.toBeCalled(); + }); + + it('should call the callback when metadata changes', async () => { + const item = storage.defineItem( + `local:key`, + ); + const cb = vi.fn(); + const oldMeta = { syncedAt: 123 }; + const newMeta = { syncedAt: 456 }; + + await item.setMeta(oldMeta); + item.watchMeta(cb); + await item.setMeta(newMeta); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith(newMeta, oldMeta); + }); + + it('should use an empty object when metadata is removed', async () => { + const item = storage.defineItem( + `local:key`, + ); + const cb = vi.fn(); + const oldMeta = { syncedAt: 123 }; + + await item.setMeta(oldMeta); + item.watchMeta(cb); + await item.removeMeta(); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith({}, oldMeta); + }); + + it('should remove the listener when calling the returned function', async () => { + const item = storage.defineItem(`local:key`); + const cb = vi.fn(); + + const unwatch = item.watchMeta(cb); + unwatch(); + await item.setMeta({ v: 2 }); + + expect(cb).not.toBeCalled(); + }); + }); + describe('unwatch', () => { it('should remove all watch listeners', async () => { const item = storage.defineItem(`local:key`); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 03a92bbec..754060016 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -64,7 +64,9 @@ function createStorage(): WxtStorage { value ?? fallback ?? null; const getMetaValue = (properties: any) => - typeof properties === 'object' && !Array.isArray(properties) + properties != null && + typeof properties === 'object' && + !Array.isArray(properties) ? properties : {}; @@ -136,6 +138,19 @@ function createStorage(): WxtStorage { cb: WatchCallback, ) => driver.watch(driverKey, cb); + const watchMeta = ( + driver: WxtStorageDriver, + driverKey: string, + cb: WatchCallback, + ) => + watch(driver, getMetaKey(driverKey), (newValue, oldValue) => { + const newMeta = getMetaValue(newValue); + const oldMeta = getMetaValue(oldValue); + if (dequal(newMeta, oldMeta)) return; + + cb(newMeta, oldMeta); + }); + return { getItem: async (key, opts) => { const { driver, driverKey } = resolveKey(key); @@ -389,6 +404,11 @@ function createStorage(): WxtStorage { return watch(driver, driverKey, cb); }, + watchMeta: (key, cb) => { + const { driver, driverKey } = resolveKey(key); + return watchMeta(driver, driverKey, cb); + }, + unwatch() { Object.values(drivers).forEach((driver) => { driver.unwatch(); @@ -574,6 +594,8 @@ function createStorage(): WxtStorage { cb(newValue ?? getFallback(), oldValue ?? getFallback()), ), + watchMeta: (cb) => watchMeta(driver, driverKey, cb), + migrate, }; }, @@ -839,6 +861,12 @@ export interface WxtStorage { /** Watch for changes to a specific key in storage. */ watch(key: StorageItemKey, cb: WatchCallback): Unwatch; + /** Watch for changes to metadata for a specific key in storage. */ + watchMeta>( + key: StorageItemKey, + cb: WatchCallback>, + ): Unwatch; + /** Remove all watch listeners. */ unwatch(): void; @@ -918,6 +946,9 @@ export interface WxtStorageItem< /** Listen for changes to the value in storage. */ watch(cb: WatchCallback): Unwatch; + /** Listen for changes to metadata in storage. */ + watchMeta(cb: WatchCallback>): Unwatch; + /** * If there are migrations defined on the storage item, migrate to the latest * version.