Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/2.drivers/0.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ icon: icon-park-outline:hard-disk
::
::card
---
icon: simple-icons:tauri
to: /drivers/tauri
title: Tauri Store
color: gray
---
Store data via Tauri Store Plugin in Tauri apps.
::
::card
---
icon: gg:vercel
to: /drivers/vercel
title: Vercel KV
Expand Down
42 changes: 42 additions & 0 deletions docs/2.drivers/tauri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
icon: simple-icons:tauri
---

# Tauri Store

> Store data via [Tauri Store Plugin](https://tauri.app/plugin/store) in Tauri desktop/mobile apps.

::read-more{to="https://tauri.app/plugin/store"}
Learn more about Tauri Store Plugin.
::

## Usage

**Driver name:** `tauri`

Install the Tauri store plugin in your Tauri project, then install unstorage:

:pm-install{name="@tauri-apps/plugin-store"}

Usage:

```js
import { createStorage } from "unstorage";
import tauriDriver from "unstorage/drivers/tauri";

const storage = createStorage({
driver: tauriDriver({
path: "store.json",
base: "app",
options: { autoSave: 100 },
}),
});
```

**Options:**

- `path`: Path to the store file (e.g. `"store.json"`). Required.
- `base`: Optional prefix for all keys (namespace).
- `options`: Optional [StoreOptions](https://tauri.app/plugin/store/) from `@tauri-apps/plugin-store` (e.g. `autoSave`: `false` to disable auto-save, or a number for debounce ms; default 100).

The driver supports `watch` via the store’s `onChange` listener for key updates.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@libsql/client": "^0.17.0",
"@netlify/blobs": "^10.7.0",
"@planetscale/database": "^1.19.0",
"@tauri-apps/plugin-store": "^2.4.2",
"@types/deno": "^2.5.0",
"@types/ioredis-mock": "^8.2.6",
"@types/jsdom": "^28.0.0",
Expand Down Expand Up @@ -100,6 +101,7 @@
"@deno/kv": ">=0.13.0",
"@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0",
"@planetscale/database": "^1.19.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@upstash/redis": "^1.36.2",
"@vercel/blob": ">=0.27.3",
"@vercel/functions": "^2.2.12 || ^3.0.0",
Expand Down Expand Up @@ -145,6 +147,9 @@
"@planetscale/database": {
"optional": true
},
"@tauri-apps/plugin-store": {
"optional": true
},
"@upstash/redis": {
"optional": true
},
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/_drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ import type { PlanetscaleDriverOptions as PlanetscaleOptions } from "unstorage/d
import type { RedisOptions } from "unstorage/drivers/redis";
import type { S3DriverOptions as S3Options } from "unstorage/drivers/s3";
import type { SessionStorageOptions } from "unstorage/drivers/session-storage";
import type { TauriStorageDriverOptions as TauriOptions } from "unstorage/drivers/tauri";
import type { UploadThingOptions as UploadthingOptions } from "unstorage/drivers/uploadthing";
import type { UpstashOptions } from "unstorage/drivers/upstash";
import type { VercelBlobOptions } from "unstorage/drivers/vercel-blob";
import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache";

export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-cache-binding" | "cloudflareCacheBinding" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-runtime-cache" | "vercelRuntimeCache";
export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-cache-binding" | "cloudflareCacheBinding" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "tauri" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-runtime-cache" | "vercelRuntimeCache";

export type BuiltinDriverOptions = {
"azure-app-configuration": AzureAppConfigurationOptions;
Expand Down Expand Up @@ -79,6 +80,7 @@ export type BuiltinDriverOptions = {
"s3": S3Options;
"session-storage": SessionStorageOptions;
"sessionStorage": SessionStorageOptions;
"tauri": TauriOptions;
"uploadthing": UploadthingOptions;
"upstash": UpstashOptions;
"vercel-blob": VercelBlobOptions;
Expand Down Expand Up @@ -133,6 +135,7 @@ export const builtinDrivers = {
"s3": "unstorage/drivers/s3",
"session-storage": "unstorage/drivers/session-storage",
"sessionStorage": "unstorage/drivers/session-storage",
"tauri": "unstorage/drivers/tauri",
"uploadthing": "unstorage/drivers/uploadthing",
"upstash": "unstorage/drivers/upstash",
"vercel-blob": "unstorage/drivers/vercel-blob",
Expand Down
83 changes: 83 additions & 0 deletions src/drivers/tauri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { load } from "@tauri-apps/plugin-store";

import { type DriverFactory, joinKeys, normalizeKey } from "./utils/index.ts";

const DRIVER_NAME = "tauri";

export interface TauriStorageDriverOptions {
/**
* Path to the store file (e.g. `"store.json"`).
*/
path: string;
/**
* Optional [StoreOptions](https://tauri.app/plugin/store/) (e.g. `autoSave`).
*/
options?: import("@tauri-apps/plugin-store").StoreOptions;
/**
* Optional prefix for all keys (namespace).
*/
base?: string;
}

type TauriStore = Awaited<ReturnType<typeof load>>;

const driver: DriverFactory<TauriStorageDriverOptions, Promise<TauriStore>> = (opts) => {
const base = normalizeKey(opts?.base || "");
const resolveKey = (key: string) => joinKeys(base, key);

const storePromise = load(opts.path, opts.options);

return {
name: DRIVER_NAME,
options: opts,
getInstance: () => storePromise,
async hasItem(key) {
const store = await storePromise;
return store.has(resolveKey(key));
},
async getItem(key) {
const store = await storePromise;
return store.get(resolveKey(key)) ?? null;
},
async setItem(key, value) {
const store = await storePromise;
await store.set(resolveKey(key), value);
},
async removeItem(key) {
const store = await storePromise;
return store.delete(resolveKey(key));
},
async getKeys(basePrefix) {
const store = await storePromise;
const prefix = resolveKey(basePrefix || "");
const allKeys = await store.keys();
if (!prefix) {
return base ? allKeys.map((k) => (k.startsWith(base + ":") ? k.slice(base.length + 1) : k)).filter(Boolean) : allKeys;
}
return allKeys
.filter((k) => k === prefix || k.startsWith(prefix + ":"))
.map((k) => (base ? k.slice(base.length + 1) : k))
.filter(Boolean);
},
async clear(basePrefix) {
const store = await storePromise;
const prefix = resolveKey(basePrefix || "");
const allKeys = await store.keys();
const toRemove = prefix
? allKeys.filter((k) => k === prefix || k.startsWith(prefix + ":"))
: base
? allKeys.filter((k) => k === base || k.startsWith(base + ":"))
: allKeys;
await Promise.all(toRemove.map((k) => store.delete(k)));
},
async watch(callback) {
const store = await storePromise;
return store.onChange((key, value) => {
const eventKey = base && key.startsWith(base + ":") ? key.slice(base.length + 1) : key;
callback(value === null ? "remove" : "update", eventKey);
});
Comment on lines +73 to +78
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter watch() events to the configured base namespace.

When base is set, this still forwards changes for every key in the store. A driver scoped to app will emit events for unrelated namespaces like other:*, which breaks the isolation the rest of the driver enforces.

🐛 Proposed fix
     async watch(callback) {
       const store = await storePromise;
       return store.onChange((key, value) => {
-        const eventKey = base && key.startsWith(base + ":") ? key.slice(base.length + 1) : key;
+        if (base && key !== base && !key.startsWith(`${base}:`)) {
+          return;
+        }
+        const eventKey =
+          base ? (key === base ? "" : key.slice(base.length + 1)) : key;
         callback(value === null ? "remove" : "update", eventKey);
       });
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/drivers/tauri.ts` around lines 73 - 78, The watch() handler forwards
every store change even when a base namespace is configured, so update the
store.onChange callback in the watch function to ignore events not in the
configured base: inside the onChange callback (the function created around
storePromise and base) first return early if base is set and the incoming key
does not start with base + ":"; otherwise compute eventKey exactly as now
(key.slice(base.length + 1) when base is present) and invoke callback with
"remove" or "update". This uses the existing symbols watch, storePromise, base
and store.onChange to scope emitted events to the configured namespace.

},
};
};

export default driver;
35 changes: 35 additions & 0 deletions test/drivers/tauri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterEach, describe, vi } from "vitest";
import driver from "../../src/drivers/tauri.ts";
import { testDriver } from "./utils.ts";

vi.mock("@tauri-apps/plugin-store", () => {
return {
load: vi.fn(() => {
const data = new Map<string, unknown>();
return Promise.resolve({
has: (key: string) => Promise.resolve(data.has(key)),
get: (key: string) => Promise.resolve(data.get(key) ?? null),
set: (key: string, value: unknown) =>
Promise.resolve(void data.set(key, value)),
delete: (key: string) => Promise.resolve(void data.delete(key)),
keys: () => Promise.resolve([...data.keys()]),
clear: () => Promise.resolve(void data.clear()),
onChange: (_cb: (key: string, value: unknown) => void) => () => {},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd "tauri.test.ts" --type f

Repository: unjs/unstorage

Length of output: 84


🏁 Script executed:

cat -n test/drivers/tauri.test.ts | head -50

Repository: unjs/unstorage

Length of output: 1355


🏁 Script executed:

# Check if `@tauri-apps/plugin-store` is documented in the codebase
rg "tauri-apps/plugin-store" -A 3 -B 3

Repository: unjs/unstorage

Length of output: 3834


🏁 Script executed:

# Look for Store interface or type definitions
rg "Store" test/drivers/tauri.test.ts -B 2 -A 2

Repository: unjs/unstorage

Length of output: 40


🏁 Script executed:

cat -n src/drivers/tauri.ts

Repository: unjs/unstorage

Length of output: 3304


🏁 Script executed:

# Search for how onChange is used in the driver
rg "onChange" src/drivers/tauri.ts -B 3 -A 3

Repository: unjs/unstorage

Length of output: 350


🏁 Script executed:

# Look for watch implementation
rg "watch" src/drivers/tauri.ts -B 2 -A 2

Repository: unjs/unstorage

Length of output: 241


🌐 Web query:

@tauri-apps/plugin-store 2.4.2 onChange API documentation

💡 Result:

@tauri-apps/plugin-storeStore.onChange (v2.x, incl. 2.4.2)

Signature

onChange(cb: (key: string, value: unknown) => void): Promise<UnlistenFn>
  • What it does: listens to any change in the store and calls cb(key, value) with the changed key and its new value. [1]
  • Return value: a Promise that resolves to an unlisten function (UnlistenFn) to stop listening. [1]

Docs

  • Store class API (onChange): [1]
  • Tauri JS reference page (shows onChange<T>(cb): Promise<UnlistenFn> on the Store API listing): [2]

Example

import { load } from '@tauri-apps/plugin-store';

const store = await load('store.json');

const unlisten = await store.onChange((key, value) => {
  console.log('changed:', key, value);
});

// later
unlisten();

Related API

  • onKeyChange<T>(key, cb): Promise<UnlistenFn> listens for changes to a single key. [1]

Sources: [1] [2]


🌐 Web query:

tauri plugin store onChange return type listener unsubscribe

💡 Result:

In @tauri-apps/plugin-store (Tauri v2), store.onChange returns a Promise<UnlistenFn> (i.e., you must await it, and it resolves to an unsubscribe function). (v2.tauri.app)

import { Store } from '@tauri-apps/plugin-store';

const store = new Store('settings.json');

const unlisten = await store.onChange((key, value) => {
  // handle change
});

// later: unsubscribe
unlisten();

UnlistenFn is the same concept used by Tauri’s event system: a function you call to stop listening. (tauri.app)

Citations:


Make the onChange mock return a Promise to match the plugin API.

The official @tauri-apps/plugin-store v2.4.2 API defines onChange(cb): Promise<UnlistenFn>. The current mock returns the unlisten function synchronously, which doesn't match the real API that requires awaiting the result. This can hide async subscription/unsubscription bugs.

🐛 Proposed fix
-        onChange: (_cb: (key: string, value: unknown) => void) => () => {},
+        onChange: async (_cb: (key: string, value: unknown) => void) => () => {},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onChange: (_cb: (key: string, value: unknown) => void) => () => {},
onChange: async (_cb: (key: string, value: unknown) => void) => () => {},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/drivers/tauri.test.ts` at line 17, The mock for onChange currently
returns the unlisten function synchronously; change it to return a
Promise<UnlistenFn> to match `@tauri-apps/plugin-store` v2.4.2 (i.e., have
onChange: (_cb: (key: string, value: unknown) => void) => Promise.resolve(() =>
{ /* unlisten */ })). Ensure the mock signature and callers that await the
result behave like the real API (return a Promise that resolves to the unlisten
function).

});
}),
};
});

describe("drivers: tauri", () => {
afterEach(() => {
vi.resetAllMocks();
});

testDriver({
driver: driver({ path: "store.json" }),
});

testDriver({
driver: driver({ path: "store.json", base: "app" }),
});
});