Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -181,6 +183,9 @@
"ofetch": {
"optional": true
},
"@tauri-apps/plugin-store": {
"optional": true
},
"uploadthing": {
"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
82 changes: 82 additions & 0 deletions src/drivers/tauri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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 as Promise<TauriStore>,
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;
await 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) => {
callback("update", _key);
});
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;
36 changes: 36 additions & 0 deletions test/drivers/tauri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, vi } from "vitest";
import driver from "../../src/drivers/tauri.ts";
import { testDriver } from "./utils.ts";
import { afterEach } from "node:test";

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" }),
});
});