feat(extension-store): add tryGetJson optional JSON lookup

This commit is contained in:
Darkatse 2026-05-04 15:47:56 -04:00
parent 0a296627a5
commit 8e757bdc7c
12 changed files with 207 additions and 64 deletions

View file

@ -16,6 +16,11 @@ const store = window.__TAURITAVERN__.api.extension.store;
```js
await store.setJson({ namespace: 'my-ext', key: 'settings', value: { enabled: true } });
const settings = await store.getJson({ namespace: 'my-ext', key: 'settings' });
const maybeSettings = await store.tryGetJson({ namespace: 'my-ext', key: 'settings' });
if (!maybeSettings.found) {
// Use in-code defaults; write only when the user changes settings.
}
```
### JSON 多表(额外 json / table)
@ -63,6 +68,7 @@ const blob = await store.getBlob({ namespace: 'my-ext', key: 'icon.png' });
| 方法 | 返回值 | 说明 |
| --- | --- | --- |
| `getJson({ namespace, key, table? })` | `Promise<any>` | 读取 JSON 值 |
| `tryGetJson({ namespace, key, table? })` | `Promise<{ found: boolean; value?: any }>` | 可选读取;key 不存在时返回 `{ found: false }`,其他错误仍抛出 |
| `setJson({ namespace, key, value, table? })` | `Promise<void>` | 写入 JSON 值(覆盖) |
| `updateJson({ namespace, key, value, table? })` | `Promise<void>` | 合并更新:对象会深度合并;非对象直接替换 |
| `renameKey({ namespace, key, newKey, table? })` | `Promise<void>` | 重命名 key |

View file

@ -33,6 +33,16 @@ impl ExtensionStoreService {
Ok(self.repository.get_json(namespace, table, key).await?)
}
pub async fn try_get_json(
&self,
namespace: &str,
table: Option<&str>,
key: &str,
) -> Result<Option<Value>, ApplicationError> {
let table = self.resolve_table(table);
Ok(self.repository.try_get_json(namespace, table, key).await?)
}
pub async fn set_json(
&self,
namespace: &str,

View file

@ -8,6 +8,13 @@ pub trait ExtensionStoreRepository: Send + Sync {
async fn get_json(&self, namespace: &str, table: &str, key: &str)
-> Result<Value, DomainError>;
async fn try_get_json(
&self,
namespace: &str,
table: &str,
key: &str,
) -> Result<Option<Value>, DomainError>;
async fn set_json(
&self,
namespace: &str,

View file

@ -220,6 +220,30 @@ async fn list_directories(dir: &Path) -> Result<Vec<String>, DomainError> {
Ok(dirs)
}
async fn read_json_entry(path: &Path) -> Result<Option<Value>, DomainError> {
let bytes = match fs::read(path).await {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(DomainError::InternalError(format!(
"Failed to read extension store JSON entry {}: {}",
path.display(),
error
)));
}
};
let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
DomainError::InvalidData(format!(
"Extension store entry contains invalid JSON {}: {}",
path.display(),
error
))
})?;
Ok(Some(value))
}
#[async_trait]
impl ExtensionStoreRepository for FileExtensionStoreRepository {
async fn get_json(
@ -229,29 +253,24 @@ impl ExtensionStoreRepository for FileExtensionStoreRepository {
key: &str,
) -> Result<Value, DomainError> {
let path = self.json_entry_path(namespace, table, key)?;
let bytes = fs::read(&path).await.map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
return DomainError::NotFound(format!(
"Extension store JSON entry not found: {}",
path.display()
));
}
DomainError::InternalError(format!(
"Failed to read extension store JSON entry {}: {}",
path.display(),
error
))
})?;
serde_json::from_slice::<Value>(&bytes).map_err(|error| {
DomainError::InvalidData(format!(
"Extension store entry contains invalid JSON {}: {}",
path.display(),
error
read_json_entry(&path).await?.ok_or_else(|| {
DomainError::NotFound(format!(
"Extension store JSON entry not found: {}",
path.display()
))
})
}
async fn try_get_json(
&self,
namespace: &str,
table: &str,
key: &str,
) -> Result<Option<Value>, DomainError> {
let path = self.json_entry_path(namespace, table, key)?;
read_json_entry(&path).await
}
async fn set_json(
&self,
namespace: &str,
@ -308,23 +327,7 @@ impl ExtensionStoreRepository for FileExtensionStoreRepository {
})?;
}
let mut current = match fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<Value>(&bytes).map_err(|error| {
DomainError::InvalidData(format!(
"Extension store entry contains invalid JSON {}: {}",
path.display(),
error
))
})?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Value::Null,
Err(error) => {
return Err(DomainError::InternalError(format!(
"Failed to read extension store JSON entry {}: {}",
path.display(),
error
)));
}
};
let mut current = read_json_entry(&path).await?.unwrap_or(Value::Null);
merge_json_value(&mut current, value);
@ -606,6 +609,41 @@ mod tests {
assert_eq!(keys, vec![String::from("index")]);
}
#[tokio::test]
async fn try_get_json_distinguishes_missing_from_null_and_keeps_invalid_json_fatal() {
let dir = create_temp_dir();
let repo = FileExtensionStoreRepository::new(dir.clone());
let missing = repo
.try_get_json("my-ext", "main", "settings")
.await
.unwrap();
assert_eq!(missing, None);
repo.set_json("my-ext", "main", "settings", json!(null))
.await
.unwrap();
let existing_null = repo
.try_get_json("my-ext", "main", "settings")
.await
.unwrap();
assert_eq!(existing_null, Some(json!(null)));
let invalid_path = dir
.join("my-ext")
.join("kv")
.join("main")
.join("invalid.json");
std::fs::write(&invalid_path, "{").expect("write invalid json");
let error = repo
.try_get_json("my-ext", "main", "invalid")
.await
.unwrap_err();
assert!(error.to_string().contains("invalid JSON"));
}
#[tokio::test]
async fn rename_and_delete_json_key() {
let dir = create_temp_dir();

View file

@ -16,6 +16,13 @@ pub struct ExtensionStoreBlobPayload {
pub mime_type: String,
}
#[derive(Debug, Serialize)]
pub struct ExtensionStoreJsonLookupPayload {
pub found: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
}
#[tauri::command]
pub async fn get_extension_store_json(
namespace: String,
@ -40,6 +47,35 @@ pub async fn get_extension_store_json(
)))
}
#[tauri::command]
pub async fn try_get_extension_store_json(
namespace: String,
key: String,
table: Option<String>,
app_state: State<'_, Arc<AppState>>,
) -> Result<ExtensionStoreJsonLookupPayload, CommandError> {
log_command(format!(
"try_get_extension_store_json {}:{}/{}",
namespace,
table.as_deref().unwrap_or("main"),
key
));
let value = app_state
.extension_store_service
.try_get_json(&namespace, table.as_deref(), &key)
.await
.map_err(map_command_error(format!(
"Failed to try-get extension store json {}:{}",
namespace, key
)))?;
Ok(ExtensionStoreJsonLookupPayload {
found: value.is_some(),
value,
})
}
#[tauri::command]
pub async fn set_extension_store_json(
namespace: String,

View file

@ -82,6 +82,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::group_chat_api_commands::search_group_chat_messages,
// Extension store commands (global extension persistence)
super::extension_store_commands::get_extension_store_json,
super::extension_store_commands::try_get_extension_store_json,
super::extension_store_commands::set_extension_store_json,
super::extension_store_commands::update_extension_store_json,
super::extension_store_commands::rename_extension_store_key,

View file

@ -17,14 +17,6 @@ function requireExtensionStore() {
return store;
}
function isMissingStoreValue(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('not found')
|| message.includes('no such file')
|| message.includes('enoent')
|| message.includes('os error 2');
}
function mergeSettings(value) {
return {
...DEFAULT_AGENT_SYSTEM_SETTINGS,
@ -40,22 +32,24 @@ function emitSettingsChanged(settings) {
export async function loadAgentSystemSettings() {
const store = requireExtensionStore();
try {
return mergeSettings(await store.getJson({
namespace: AGENT_SYSTEM_MODULE_NAME,
key: AGENT_SYSTEM_SETTINGS_KEY,
}));
} catch (error) {
if (!isMissingStoreValue(error)) {
throw error;
}
await store.setJson({
namespace: AGENT_SYSTEM_MODULE_NAME,
key: AGENT_SYSTEM_SETTINGS_KEY,
value: DEFAULT_AGENT_SYSTEM_SETTINGS,
});
if (typeof store.tryGetJson !== 'function') {
throw new Error('TauriTavern extension store tryGetJson API is unavailable');
}
const result = await store.tryGetJson({
namespace: AGENT_SYSTEM_MODULE_NAME,
key: AGENT_SYSTEM_SETTINGS_KEY,
});
if (typeof result?.found !== 'boolean') {
throw new Error('TauriTavern extension store tryGetJson returned an invalid response');
}
if (!result.found) {
return { ...DEFAULT_AGENT_SYSTEM_SETTINGS };
}
return mergeSettings(result.value);
}
export async function saveAgentSystemSettings(settings) {

View file

@ -34,6 +34,13 @@ function createExtensionStoreApi({ safeInvoke }) {
return safeInvoke('get_extension_store_json', { namespace, key, table });
}
async function tryGetJson(options) {
const namespace = requireNonEmptyString(options?.namespace, 'namespace');
const key = requireNonEmptyString(options?.key, 'key');
const table = normalizeOptionalName(options?.table);
return safeInvoke('try_get_extension_store_json', { namespace, key, table });
}
async function setJson(options) {
const namespace = requireNonEmptyString(options?.namespace, 'namespace');
const key = requireNonEmptyString(options?.key, 'key');
@ -138,6 +145,7 @@ function createExtensionStoreApi({ safeInvoke }) {
return {
getJson,
tryGetJson,
setJson,
updateJson,
updateJSON: updateJson,

View file

@ -136,6 +136,7 @@ export {};
* | 'delete_character_chat_store_json'
* | 'delete_group_chat_store_json'
* | 'get_extension_store_json'
* | 'try_get_extension_store_json'
* | 'set_extension_store_json'
* | 'update_extension_store_json'
* | 'rename_extension_store_key'

1
src/types.d.ts vendored
View file

@ -425,6 +425,7 @@ type TauriTavernWorldInfoApi = {
type TauriTavernExtensionStoreApi = {
getJson: (options: { namespace: string; key: string; table?: string }) => Promise<any>;
tryGetJson: (options: { namespace: string; key: string; table?: string }) => Promise<{ found: boolean; value?: any }>;
setJson: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;
updateJson: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;
updateJSON: (options: { namespace: string; key: string; value: any; table?: string }) => Promise<void>;

View file

@ -39,11 +39,11 @@ test('Agent System settings use the extension store and publish changes', async
installWindow({
extension: {
store: {
async getJson() {
async tryGetJson() {
if (stored === null) {
throw new Error('not found');
return { found: false };
}
return stored;
return { found: true, value: stored };
},
async setJson(request) {
writes.push(request);
@ -60,7 +60,7 @@ test('Agent System settings use the extension store and publish changes', async
selectedProfileId: 'default-writer',
activeTab: 'profiles',
});
assert.equal(writes.length, 1);
assert.equal(writes.length, 0);
let emitted = null;
const unsubscribe = settings.subscribeAgentSystemSettings((next) => {
@ -89,8 +89,8 @@ test('Agent generation router uses the global toggle for normal regenerate and s
installWindow({
extension: {
store: {
async getJson() {
return stored;
async tryGetJson() {
return { found: true, value: stored };
},
async setJson(request) {
stored = request.value;

View file

@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(relativePath) {
const modulePath = path.join(REPO_ROOT, relativePath);
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
test('extension store tryGetJson uses optional lookup command', async () => {
const calls = [];
globalThis.window = { __TAURITAVERN__: { api: {} } };
const { installExtensionStoreApi } = await importFresh('src/tauri/main/api/extension-store.js');
installExtensionStoreApi({
async safeInvoke(command, args) {
calls.push({ command, args });
return { found: false };
},
});
const result = await window.__TAURITAVERN__.api.extension.store.tryGetJson({
namespace: 'my-ext',
table: 'state',
key: 'settings',
});
assert.deepEqual(result, { found: false });
assert.deepEqual(calls, [{
command: 'try_get_extension_store_json',
args: {
namespace: 'my-ext',
table: 'state',
key: 'settings',
},
}]);
});