diff --git a/plugins/README.md b/plugins/README.md index 13cdc2abd..f5835c0b5 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -14,6 +14,7 @@ Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugi - [HTTP Requests](#http-requests) - [Scheduler](#scheduler) - [Cache](#cache) + - [KVStore](#kvstore) - [WebSocket](#websocket) - [Library](#library) - [Artwork](#artwork) @@ -379,6 +380,73 @@ if result.Exists { > **Note:** Cache is in-memory only and cleared on server restart. +### KVStore + +Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database. + +**Manifest permission:** + +```json +{ + "permissions": { + "kvstore": { + "reason": "Store OAuth tokens and plugin state", + "maxSize": "1MB" + } + } +} +``` + +**Permission options:** +- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB + +**Host functions:** + +| Function | Parameters | Description | +|--------------------------|--------------|-----------------------------------| +| `kvstore_set` | `key, value` | Store a byte value | +| `kvstore_get` | `key` | Retrieve a byte value | +| `kvstore_delete` | `key` | Delete a value | +| `kvstore_has` | `key` | Check if key exists | +| `kvstore_list` | `prefix` | List keys matching prefix | +| `kvstore_getstorageused` | - | Get current storage usage (bytes) | + +**Key constraints:** +- Maximum key length: 256 bytes +- Keys must be valid UTF-8 strings + +**Usage (with generated SDK):** + +Copy `plugins/host/go/nd_host_kvstore.go` to your plugin: + +```go +// Store a value (as raw bytes) +token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) +_, err := KVStoreSet("oauth:spotify", token) + +// Retrieve a value +result, err := KVStoreGet("oauth:spotify") +if result.Exists { + var tokenData map[string]string + json.Unmarshal(result.Value, &tokenData) +} + +// List all keys with prefix +keysResult, err := KVStoreList("user:") +for _, key := range keysResult.Keys { + // Process each key +} + +// Check storage usage +usageResult, err := KVStoreGetStorageUsed() +fmt.Printf("Using %d bytes\n", usageResult.Bytes) + +// Delete a value +KVStoreDelete("oauth:spotify") +``` + +> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`. + ### WebSocket Establish persistent WebSocket connections to external services. diff --git a/plugins/host/go/nd_host_kvstore.go b/plugins/host/go/nd_host_kvstore.go new file mode 100644 index 000000000..a59028a6b --- /dev/null +++ b/plugins/host/go/nd_host_kvstore.go @@ -0,0 +1,336 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the KVStore host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// kvstore_set is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_set +func kvstore_set(uint64) uint64 + +// kvstore_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_get +func kvstore_get(uint64) uint64 + +// kvstore_delete is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_delete +func kvstore_delete(uint64) uint64 + +// kvstore_has is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_has +func kvstore_has(uint64) uint64 + +// kvstore_list is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_list +func kvstore_list(uint64) uint64 + +// kvstore_getstorageused is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_getstorageused +func kvstore_getstorageused(uint64) uint64 + +// KVStoreSetRequest is the request type for KVStore.Set. +type KVStoreSetRequest struct { + Key string `json:"key"` + Value []byte `json:"value"` +} + +// KVStoreSetResponse is the response type for KVStore.Set. +type KVStoreSetResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreGetRequest is the request type for KVStore.Get. +type KVStoreGetRequest struct { + Key string `json:"key"` +} + +// KVStoreGetResponse is the response type for KVStore.Get. +type KVStoreGetResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreDeleteRequest is the request type for KVStore.Delete. +type KVStoreDeleteRequest struct { + Key string `json:"key"` +} + +// KVStoreDeleteResponse is the response type for KVStore.Delete. +type KVStoreDeleteResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreHasRequest is the request type for KVStore.Has. +type KVStoreHasRequest struct { + Key string `json:"key"` +} + +// KVStoreHasResponse is the response type for KVStore.Has. +type KVStoreHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreListRequest is the request type for KVStore.List. +type KVStoreListRequest struct { + Prefix string `json:"prefix"` +} + +// KVStoreListResponse is the response type for KVStore.List. +type KVStoreListResponse struct { + Keys []string `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed. +type KVStoreGetStorageUsedResponse struct { + Bytes int64 `json:"bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreSet calls the kvstore_set host function. +// Set stores a byte value with the given key. +// +// Parameters: +// - key: The storage key (max 256 bytes, UTF-8) +// - value: The byte slice to store +// +// Returns an error if the storage limit would be exceeded or the operation fails. +func KVStoreSet(key string, value []byte) (*KVStoreSetResponse, error) { + // Marshal request to JSON + req := KVStoreSetRequest{ + Key: key, + Value: value, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_set(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreSetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreGet calls the kvstore_get host function. +// Get retrieves a byte value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns the value and whether the key exists. +func KVStoreGet(key string) (*KVStoreGetResponse, error) { + // Marshal request to JSON + req := KVStoreGetRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_get(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreDelete calls the kvstore_delete host function. +// Delete removes a value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func KVStoreDelete(key string) (*KVStoreDeleteResponse, error) { + // Marshal request to JSON + req := KVStoreDeleteRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_delete(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreDeleteResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreHas calls the kvstore_has host function. +// Has checks if a key exists in storage. +// +// Parameters: +// - key: The storage key +// +// Returns true if the key exists. +func KVStoreHas(key string) (*KVStoreHasResponse, error) { + // Marshal request to JSON + req := KVStoreHasRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_has(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreHasResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreList calls the kvstore_list host function. +// List returns all keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to filter by (empty string returns all keys) +// +// Returns a slice of matching keys. +func KVStoreList(prefix string) (*KVStoreListResponse, error) { + // Marshal request to JSON + req := KVStoreListRequest{ + Prefix: prefix, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_list(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreListResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreGetStorageUsed calls the kvstore_getstorageused host function. +// GetStorageUsed returns the total storage used by this plugin in bytes. +func KVStoreGetStorageUsed() (*KVStoreGetStorageUsedResponse, error) { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_getstorageused(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreGetStorageUsedResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} diff --git a/plugins/host/kvstore.go b/plugins/host/kvstore.go new file mode 100644 index 000000000..4d9dafd20 --- /dev/null +++ b/plugins/host/kvstore.go @@ -0,0 +1,65 @@ +package host + +import "context" + +// KVStoreService provides persistent key-value storage for plugins. +// +// Unlike CacheService which is in-memory only, KVStoreService persists data +// to disk and survives server restarts. Each plugin has its own isolated +// storage with configurable size limits. +// +// Values are stored as raw bytes, giving plugins full control over +// serialization (JSON, protobuf, etc.). +// +//nd:hostservice name=KVStore permission=kvstore +type KVStoreService interface { + // Set stores a byte value with the given key. + // + // Parameters: + // - key: The storage key (max 256 bytes, UTF-8) + // - value: The byte slice to store + // + // Returns an error if the storage limit would be exceeded or the operation fails. + //nd:hostfunc + Set(ctx context.Context, key string, value []byte) error + + // Get retrieves a byte value from storage. + // + // Parameters: + // - key: The storage key + // + // Returns the value and whether the key exists. + //nd:hostfunc + Get(ctx context.Context, key string) (value []byte, exists bool, err error) + + // Delete removes a value from storage. + // + // Parameters: + // - key: The storage key + // + // Returns an error if the operation fails. Does not return an error if the key doesn't exist. + //nd:hostfunc + Delete(ctx context.Context, key string) error + + // Has checks if a key exists in storage. + // + // Parameters: + // - key: The storage key + // + // Returns true if the key exists. + //nd:hostfunc + Has(ctx context.Context, key string) (exists bool, err error) + + // List returns all keys matching the given prefix. + // + // Parameters: + // - prefix: Key prefix to filter by (empty string returns all keys) + // + // Returns a slice of matching keys. + //nd:hostfunc + List(ctx context.Context, prefix string) (keys []string, err error) + + // GetStorageUsed returns the total storage used by this plugin in bytes. + //nd:hostfunc + GetStorageUsed(ctx context.Context) (bytes int64, err error) +} diff --git a/plugins/host/kvstore_gen.go b/plugins/host/kvstore_gen.go new file mode 100644 index 000000000..422199f23 --- /dev/null +++ b/plugins/host/kvstore_gen.go @@ -0,0 +1,297 @@ +// Code generated by hostgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// KVStoreSetRequest is the request type for KVStore.Set. +type KVStoreSetRequest struct { + Key string `json:"key"` + Value []byte `json:"value"` +} + +// KVStoreSetResponse is the response type for KVStore.Set. +type KVStoreSetResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreGetRequest is the request type for KVStore.Get. +type KVStoreGetRequest struct { + Key string `json:"key"` +} + +// KVStoreGetResponse is the response type for KVStore.Get. +type KVStoreGetResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreDeleteRequest is the request type for KVStore.Delete. +type KVStoreDeleteRequest struct { + Key string `json:"key"` +} + +// KVStoreDeleteResponse is the response type for KVStore.Delete. +type KVStoreDeleteResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreHasRequest is the request type for KVStore.Has. +type KVStoreHasRequest struct { + Key string `json:"key"` +} + +// KVStoreHasResponse is the response type for KVStore.Has. +type KVStoreHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreListRequest is the request type for KVStore.List. +type KVStoreListRequest struct { + Prefix string `json:"prefix"` +} + +// KVStoreListResponse is the response type for KVStore.List. +type KVStoreListResponse struct { + Keys []string `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed. +type KVStoreGetStorageUsedResponse struct { + Bytes int64 `json:"bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterKVStoreHostFunctions registers KVStore service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterKVStoreHostFunctions(service KVStoreService) []extism.HostFunction { + return []extism.HostFunction{ + newKVStoreSetHostFunction(service), + newKVStoreGetHostFunction(service), + newKVStoreDeleteHostFunction(service), + newKVStoreHasHostFunction(service), + newKVStoreListHostFunction(service), + newKVStoreGetStorageUsedHostFunction(service), + } +} + +func newKVStoreSetHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_set", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreSetRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.Set(ctx, req.Key, req.Value); svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreSetResponse{} + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_get", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreGetRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + value, exists, svcErr := service.Get(ctx, req.Key) + if svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreGetResponse{ + Value: value, + Exists: exists, + } + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_delete", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreDeleteRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + if svcErr := service.Delete(ctx, req.Key); svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreDeleteResponse{} + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreHasHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_has", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreHasRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + exists, svcErr := service.Has(ctx, req.Key) + if svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreHasResponse{ + Exists: exists, + } + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreListHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_list", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + var req KVStoreListRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + kvstoreWriteError(p, stack, err) + return + } + + // Call the service method + keys, svcErr := service.List(ctx, req.Prefix) + if svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreListResponse{ + Keys: keys, + } + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newKVStoreGetStorageUsedHostFunction(service KVStoreService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "kvstore_getstorageused", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + + // Call the service method + bytes, svcErr := service.GetStorageUsed(ctx) + if svcErr != nil { + kvstoreWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := KVStoreGetStorageUsedResponse{ + Bytes: bytes, + } + kvstoreWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// kvstoreWriteResponse writes a JSON response to plugin memory. +func kvstoreWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + kvstoreWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// kvstoreWriteError writes an error response to plugin memory. +func kvstoreWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host/python/nd_host_kvstore.py b/plugins/host/python/nd_host_kvstore.py new file mode 100644 index 000000000..f1a6e5724 --- /dev/null +++ b/plugins/host/python/nd_host_kvstore.py @@ -0,0 +1,241 @@ +# Code generated by hostgen. DO NOT EDIT. +# +# This file contains client wrappers for the KVStore host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "kvstore_set") +def _kvstore_set(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_get") +def _kvstore_get(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_delete") +def _kvstore_delete(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_has") +def _kvstore_has(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_list") +def _kvstore_list(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@extism.import_fn("extism:host/user", "kvstore_getstorageused") +def _kvstore_getstorageused(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +@dataclass +class KVStoreGetResult: + """Result type for kvstore_get.""" + value: bytes + exists: bool + + +def kvstore_set(key: str, value: bytes) -> None: + """Set stores a byte value with the given key. + +Parameters: + - key: The storage key (max 256 bytes, UTF-8) + - value: The byte slice to store + +Returns an error if the storage limit would be exceeded or the operation fails. + + Args: + key: str parameter. + value: bytes parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + "value": value, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_set(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + +def kvstore_get(key: str) -> KVStoreGetResult: + """Get retrieves a byte value from storage. + +Parameters: + - key: The storage key + +Returns the value and whether the key exists. + + Args: + key: str parameter. + + Returns: + KVStoreGetResult containing value, exists,. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_get(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return KVStoreGetResult( + value=response.get("value", b""), + exists=response.get("exists", False), + ) + + +def kvstore_delete(key: str) -> None: + """Delete removes a value from storage. + +Parameters: + - key: The storage key + +Returns an error if the operation fails. Does not return an error if the key doesn't exist. + + Args: + key: str parameter. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_delete(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + + +def kvstore_has(key: str) -> bool: + """Has checks if a key exists in storage. + +Parameters: + - key: The storage key + +Returns true if the key exists. + + Args: + key: str parameter. + + Returns: + bool: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "key": key, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_has(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("exists", False) + + +def kvstore_list(prefix: str) -> Any: + """List returns all keys matching the given prefix. + +Parameters: + - prefix: Key prefix to filter by (empty string returns all keys) + +Returns a slice of matching keys. + + Args: + prefix: str parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "prefix": prefix, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_list(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("keys", None) + + +def kvstore_get_storage_used() -> int: + """GetStorageUsed returns the total storage used by this plugin in bytes. + + Returns: + int: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request_bytes = b"{}" + request_mem = extism.memory.alloc(request_bytes) + response_offset = _kvstore_getstorageused(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("bytes", 0) diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go new file mode 100644 index 000000000..53d4da922 --- /dev/null +++ b/plugins/host_kvstore.go @@ -0,0 +1,250 @@ +package plugins + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/dustin/go-humanize" + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host" +) + +const ( + defaultMaxKVStoreSize = 1 * 1024 * 1024 // 1MB default + maxKeyLength = 256 // Max key length in bytes +) + +// kvstoreServiceImpl implements the host.KVStoreService interface. +// Each plugin gets its own SQLite database for isolation. +type kvstoreServiceImpl struct { + pluginName string + db *sql.DB + maxSize int64 + currentSize atomic.Int64 // cached total size, updated on Set/Delete +} + +// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database. +func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) { + // Parse max size from permission, default to 1MB + maxSize := int64(defaultMaxKVStoreSize) + if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" { + parsed, err := humanize.ParseBytes(*perm.MaxSize) + if err != nil { + return nil, fmt.Errorf("invalid maxSize %q: %w", *perm.MaxSize, err) + } + maxSize = int64(parsed) + } + + // Create plugin data directory + dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating plugin data directory: %w", err) + } + + // Open SQLite database + dbPath := filepath.Join(dataDir, "kvstore.db") + db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off") + if err != nil { + return nil, fmt.Errorf("opening kvstore database: %w", err) + } + + db.SetMaxOpenConns(3) + db.SetMaxIdleConns(1) + + // Create schema + if err := createKVStoreSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("creating kvstore schema: %w", err) + } + + // Load current storage size from database + var currentSize int64 + if err := db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM kvstore`).Scan(¤tSize); err != nil { + db.Close() + return nil, fmt.Errorf("loading storage size: %w", err) + } + + log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)), "currentSize", humanize.Bytes(uint64(currentSize))) + + svc := &kvstoreServiceImpl{ + pluginName: pluginName, + db: db, + maxSize: maxSize, + } + svc.currentSize.Store(currentSize) + return svc, nil +} + +func createKVStoreSchema(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS kvstore ( + key TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL, + size INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + return err +} + +// Set stores a byte value with the given key. +func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error { + // Validate key + if len(key) == 0 { + return fmt.Errorf("key cannot be empty") + } + if len(key) > maxKeyLength { + return fmt.Errorf("key exceeds maximum length of %d bytes", maxKeyLength) + } + + newValueSize := int64(len(value)) + + // Get current size of this key (if it exists) to calculate delta + var oldSize int64 + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ?`, key).Scan(&oldSize) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("checking existing key: %w", err) + } + + // Check size limits using cached total + delta := newValueSize - oldSize + newTotal := s.currentSize.Load() + delta + if newTotal > s.maxSize { + return fmt.Errorf("storage limit exceeded: would use %s of %s allowed", + humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize))) + } + + // Upsert the value + _, err = s.db.ExecContext(ctx, ` + INSERT INTO kvstore (key, value, size, created_at, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + size = excluded.size, + updated_at = CURRENT_TIMESTAMP + `, key, value, newValueSize) + if err != nil { + return fmt.Errorf("storing value: %w", err) + } + + // Update cached size + s.currentSize.Add(delta) + + log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize) + return nil +} + +// Get retrieves a byte value from storage. +func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) { + var value []byte + err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ?`, key).Scan(&value) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("reading value: %w", err) + } + + log.Trace(ctx, "KVStore.Get", "plugin", s.pluginName, "key", key, "found", true) + return value, true, nil +} + +// Delete removes a value from storage. +func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error { + // Get size of the key being deleted to update cache + var oldSize int64 + err := s.db.QueryRowContext(ctx, `SELECT size FROM kvstore WHERE key = ?`, key).Scan(&oldSize) + if errors.Is(err, sql.ErrNoRows) { + // Key doesn't exist, nothing to delete + return nil + } + if err != nil { + return fmt.Errorf("checking key size: %w", err) + } + + _, err = s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key) + if err != nil { + return fmt.Errorf("deleting value: %w", err) + } + + // Update cached size + s.currentSize.Add(-oldSize) + + log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key) + return nil +} + +// Has checks if a key exists in storage. +func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ?`, key).Scan(&count) + if err != nil { + return false, fmt.Errorf("checking key: %w", err) + } + + return count > 0, nil +} + +// List returns all keys matching the given prefix. +func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, error) { + var rows *sql.Rows + var err error + + if prefix == "" { + rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore ORDER BY key`) + } else { + // Escape special LIKE characters in prefix + escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%") + escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_") + rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' ORDER BY key`, escapedPrefix+"%") + } + if err != nil { + return nil, fmt.Errorf("listing keys: %w", err) + } + defer rows.Close() + + var keys []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, fmt.Errorf("scanning key: %w", err) + } + keys = append(keys, key) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating keys: %w", err) + } + + log.Trace(ctx, "KVStore.List", "plugin", s.pluginName, "prefix", prefix, "count", len(keys)) + return keys, nil +} + +// GetStorageUsed returns the total storage used by this plugin in bytes. +func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) { + used := s.currentSize.Load() + log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used) + return used, nil +} + +// Close closes the SQLite database connection. +// This is called when the plugin is unloaded. +func (s *kvstoreServiceImpl) Close() error { + if s.db != nil { + log.Debug("Closing plugin kvstore", "plugin", s.pluginName) + return s.db.Close() + } + return nil +} + +// Compile-time verification +var _ host.KVStoreService = (*kvstoreServiceImpl)(nil) diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go new file mode 100644 index 000000000..1c863ebea --- /dev/null +++ b/plugins/host_kvstore_test.go @@ -0,0 +1,582 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("KVStoreService", func() { + var tmpDir string + var service *kvstoreServiceImpl + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + var err error + tmpDir, err = os.MkdirTemp("", "kvstore-test-*") + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = tmpDir + + // Create service with 1KB limit for testing + maxSize := "1KB" + service, err = newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize}) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if service != nil { + service.Close() + } + os.RemoveAll(tmpDir) + }) + + Describe("Basic Operations", func() { + It("sets and gets a value", func() { + err := service.Set(ctx, "key1", []byte("value1")) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.Get(ctx, "key1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("value1"))) + }) + + It("returns not exists for missing key", func() { + value, exists, err := service.Get(ctx, "missing_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(BeNil()) + }) + + It("overwrites existing key", func() { + err := service.Set(ctx, "key1", []byte("value1")) + Expect(err).ToNot(HaveOccurred()) + + err = service.Set(ctx, "key1", []byte("value2")) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.Get(ctx, "key1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal([]byte("value2"))) + }) + + It("handles binary data", func() { + binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD} + err := service.Set(ctx, "binary", binaryData) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.Get(ctx, "binary") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(binaryData)) + }) + }) + + Describe("Delete Operation", func() { + It("deletes a value", func() { + err := service.Set(ctx, "delete_me", []byte("value")) + Expect(err).ToNot(HaveOccurred()) + + err = service.Delete(ctx, "delete_me") + Expect(err).ToNot(HaveOccurred()) + + _, exists, err := service.Get(ctx, "delete_me") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("does not error when deleting non-existing key", func() { + err := service.Delete(ctx, "never_existed") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Has Operation", func() { + It("returns true for existing key", func() { + err := service.Set(ctx, "exists_key", []byte("value")) + Expect(err).ToNot(HaveOccurred()) + + exists, err := service.Has(ctx, "exists_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("returns false for non-existing key", func() { + exists, err := service.Has(ctx, "non_existing_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + }) + + Describe("List Operation", func() { + BeforeEach(func() { + Expect(service.Set(ctx, "user:1:name", []byte("Alice"))).To(Succeed()) + Expect(service.Set(ctx, "user:1:email", []byte("alice@test.com"))).To(Succeed()) + Expect(service.Set(ctx, "user:2:name", []byte("Bob"))).To(Succeed()) + Expect(service.Set(ctx, "config:theme", []byte("dark"))).To(Succeed()) + }) + + It("lists all keys with empty prefix", func() { + keys, err := service.List(ctx, "") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(4)) + Expect(keys).To(ContainElements("config:theme", "user:1:email", "user:1:name", "user:2:name")) + }) + + It("lists keys matching prefix", func() { + keys, err := service.List(ctx, "user:1:") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(2)) + Expect(keys).To(ContainElements("user:1:name", "user:1:email")) + }) + + It("lists keys matching partial prefix", func() { + keys, err := service.List(ctx, "user:") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(3)) + }) + + It("returns empty list for non-matching prefix", func() { + keys, err := service.List(ctx, "notfound:") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(BeEmpty()) + }) + + It("handles special LIKE characters in prefix", func() { + // Add keys with special characters + Expect(service.Set(ctx, "test%key", []byte("value1"))).To(Succeed()) + Expect(service.Set(ctx, "test_key", []byte("value2"))).To(Succeed()) + Expect(service.Set(ctx, "testXkey", []byte("value3"))).To(Succeed()) + + // Search for "test%" + keys, err := service.List(ctx, "test%") + Expect(err).ToNot(HaveOccurred()) + Expect(keys).To(HaveLen(1)) + Expect(keys).To(ContainElement("test%key")) + }) + }) + + Describe("Storage Usage", func() { + It("reports correct storage used", func() { + used, err := service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(0))) + + err = service.Set(ctx, "key1", []byte("12345")) + Expect(err).ToNot(HaveOccurred()) + + used, err = service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(5))) + + err = service.Set(ctx, "key2", []byte("67890")) + Expect(err).ToNot(HaveOccurred()) + + used, err = service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(10))) + }) + + It("updates storage when value is overwritten", func() { + err := service.Set(ctx, "key1", []byte("12345")) + Expect(err).ToNot(HaveOccurred()) + + used, _ := service.GetStorageUsed(ctx) + Expect(used).To(Equal(int64(5))) + + // Overwrite with smaller value + err = service.Set(ctx, "key1", []byte("ab")) + Expect(err).ToNot(HaveOccurred()) + + used, _ = service.GetStorageUsed(ctx) + Expect(used).To(Equal(int64(2))) + }) + + It("decreases storage when key is deleted", func() { + Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed()) + Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed()) + + used, err := service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(10))) + + Expect(service.Delete(ctx, "key1")).To(Succeed()) + + used, err = service.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(5))) + }) + + It("updates storage when value is overwritten with larger value", func() { + err := service.Set(ctx, "key1", []byte("ab")) + Expect(err).ToNot(HaveOccurred()) + + used, _ := service.GetStorageUsed(ctx) + Expect(used).To(Equal(int64(2))) + + // Overwrite with larger value + err = service.Set(ctx, "key1", []byte("12345")) + Expect(err).ToNot(HaveOccurred()) + + used, _ = service.GetStorageUsed(ctx) + Expect(used).To(Equal(int64(5))) + }) + + It("restores correct size after service restart", func() { + // Add some data + Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed()) + Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed()) + + used, _ := service.GetStorageUsed(ctx) + Expect(used).To(Equal(int64(10))) + + // Close and reopen the service (simulating restart) + Expect(service.Close()).To(Succeed()) + + maxSize := "1KB" + service2, err := newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize}) + Expect(err).ToNot(HaveOccurred()) + defer service2.Close() + + // Size should be restored from database + used, err = service2.GetStorageUsed(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(used).To(Equal(int64(10))) + }) + }) + + Describe("Size Limits", func() { + It("rejects value when storage limit would be exceeded", func() { + // Service has 1KB limit + bigValue := make([]byte, 2048) + err := service.Set(ctx, "big", bigValue) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) + }) + + It("allows updating existing key even if total would exceed limit", func() { + // Fill up most of the storage + almostFull := make([]byte, 900) + err := service.Set(ctx, "big", almostFull) + Expect(err).ToNot(HaveOccurred()) + + // Overwrite with same size should work + err = service.Set(ctx, "big", almostFull) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Key Validation", func() { + It("rejects empty key", func() { + err := service.Set(ctx, "", []byte("value")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key cannot be empty")) + }) + + It("rejects key exceeding max length", func() { + longKey := strings.Repeat("a", 300) + err := service.Set(ctx, longKey, []byte("value")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key exceeds maximum length")) + }) + }) + + Describe("Plugin Isolation", func() { + It("isolates data between plugins", func() { + service2, err := newKVStoreService("other_plugin", &KVStorePermission{}) + Expect(err).ToNot(HaveOccurred()) + defer service2.Close() + + // Set same key in both plugins + err = service.Set(ctx, "shared", []byte("value1")) + Expect(err).ToNot(HaveOccurred()) + err = service2.Set(ctx, "shared", []byte("value2")) + Expect(err).ToNot(HaveOccurred()) + + // Each plugin should get their own value + val1, _, _ := service.Get(ctx, "shared") + Expect(val1).To(Equal([]byte("value1"))) + + val2, _, _ := service2.Get(ctx, "shared") + Expect(val2).To(Equal([]byte("value2"))) + }) + + It("creates separate database files per plugin", func() { + service2, err := newKVStoreService("other_plugin", &KVStorePermission{}) + Expect(err).ToNot(HaveOccurred()) + defer service2.Close() + + // Check that separate directories exist + _, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "kvstore.db")) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "kvstore.db")) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Close", func() { + It("closes database connection", func() { + err := service.Close() + Expect(err).ToNot(HaveOccurred()) + + // After close, operations should fail + _, _, err = service.Get(ctx, "any") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("KVStoreService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "kvstore-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-kvstore plugin + srcPath := filepath.Join(testdataDir, "test-kvstore"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-kvstore"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + conf.Server.DataFolder = tmpDir + + // Setup mock DataStore with pre-enabled plugin + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-kvstore", + Path: destPath, + SHA256: hashHex, + Enabled: true, + }}) + dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("should load plugin with kvstore permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-kvstore"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Kvstore).ToNot(BeNil()) + Expect(*p.manifest.Permissions.Kvstore.MaxSize).To(Equal("10KB")) + }) + }) + + Describe("KVStore Operations via Plugin", func() { + type testKVStoreInput struct { + Operation string `json:"operation"` + Key string `json:"key"` + Value []byte `json:"value,omitempty"` + Prefix string `json:"prefix,omitempty"` + } + type testKVStoreOutput struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Keys []string `json:"keys,omitempty"` + StorageUsed int64 `json:"storage_used,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-kvstore"] + manager.mu.RUnlock() + + instance, err := p.instance() + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_kvstore", inputBytes) + if err != nil { + return nil, err + } + + var output testKVStoreOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + It("should set and get value", func() { + ctx := GinkgoT().Context() + + // Set value + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: "test_key", + Value: []byte("hello kvstore"), + }) + Expect(err).ToNot(HaveOccurred()) + + // Get value + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "get", + Key: "test_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.Value).To(Equal([]byte("hello kvstore"))) + }) + + It("should check key existence with has", func() { + ctx := GinkgoT().Context() + + // Check existing key + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "has", + Key: "test_key", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + + // Check non-existing key + output, err = callTestKVStore(ctx, testKVStoreInput{ + Operation: "has", + Key: "non_existing", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should delete value", func() { + ctx := GinkgoT().Context() + + // Set another key + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: "to_delete", + Value: []byte("delete me"), + }) + Expect(err).ToNot(HaveOccurred()) + + // Delete it + _, err = callTestKVStore(ctx, testKVStoreInput{ + Operation: "delete", + Key: "to_delete", + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify it's gone + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "has", + Key: "to_delete", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should list keys with prefix", func() { + ctx := GinkgoT().Context() + + // Set some keys + for _, key := range []string{"prefix:1", "prefix:2", "other:1"} { + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: key, + Value: []byte("value"), + }) + Expect(err).ToNot(HaveOccurred()) + } + + // List with prefix + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "list", + Prefix: "prefix:", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Keys).To(HaveLen(2)) + Expect(output.Keys).To(ContainElements("prefix:1", "prefix:2")) + }) + + It("should report storage used", func() { + ctx := GinkgoT().Context() + + output, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "get_storage_used", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.StorageUsed).To(BeNumerically(">", 0)) + }) + + It("should enforce size limits", func() { + ctx := GinkgoT().Context() + + // Plugin has 10KB limit, try to exceed it + bigValue := make([]byte, 15*1024) + _, err := callTestKVStore(ctx, testKVStoreInput{ + Operation: "set", + Key: "too_big", + Value: bigValue, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) + }) + }) + + Describe("Database Isolation", func() { + It("should create separate database file for plugin", func() { + dbPath := filepath.Join(tmpDir, "plugins", "test-kvstore", "kvstore.db") + _, err := os.Stat(dbPath) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 3e8a13a50..4c4125c45 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -83,6 +83,19 @@ var hostServices = []hostServiceEntry{ return host.RegisterLibraryHostFunctions(service), nil }, }, + { + name: "KVStore", + hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + perm := ctx.permissions.Kvstore + service, err := newKVStoreService(ctx.pluginName, perm) + if err != nil { + log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err) + return nil, nil + } + return host.RegisterKVStoreHostFunctions(service), service + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest.json b/plugins/manifest-schema.json similarity index 90% rename from plugins/manifest.json rename to plugins/manifest-schema.json index 7e0fc9a85..4d8e23880 100644 --- a/plugins/manifest.json +++ b/plugins/manifest-schema.json @@ -61,6 +61,9 @@ }, "library": { "$ref": "#/$defs/LibraryPermission" + }, + "kvstore": { + "$ref": "#/$defs/KVStorePermission" } } }, @@ -182,6 +185,21 @@ "default": false } } + }, + "KVStorePermission": { + "type": "object", + "description": "Key-value store permissions for persistent plugin storage", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why key-value store access is needed" + }, + "maxSize": { + "type": "string", + "description": "Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index f8bbe979f..17906da80 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -1,6 +1,6 @@ package plugins -//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest.json +//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json // AllowedHosts returns a list of allowed hosts for HTTP requests. // Returns the hosts directly from the manifest's permissions. diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index a7d4df873..ee5cf37ee 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -33,6 +33,15 @@ type HTTPPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Key-value store permissions for persistent plugin storage +type KVStorePermission struct { + // Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB + MaxSize *string `json:"maxSize,omitempty" yaml:"maxSize,omitempty" mapstructure:"maxSize,omitempty"` + + // Explanation for why key-value store access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Library service permissions for accessing library metadata and optionally // filesystem type LibraryPermission struct { @@ -126,6 +135,9 @@ type Permissions struct { // Http corresponds to the JSON schema field "http". Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` + // Kvstore corresponds to the JSON schema field "kvstore". + Kvstore *KVStorePermission `json:"kvstore,omitempty" yaml:"kvstore,omitempty" mapstructure:"kvstore,omitempty"` + // Library corresponds to the JSON schema field "library". Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` diff --git a/plugins/testdata/test-kvstore/go.mod b/plugins/testdata/test-kvstore/go.mod new file mode 100644 index 000000000..48d1417e9 --- /dev/null +++ b/plugins/testdata/test-kvstore/go.mod @@ -0,0 +1,5 @@ +module test-kvstore + +go 1.23 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/test-kvstore/go.sum b/plugins/testdata/test-kvstore/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/test-kvstore/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/testdata/test-kvstore/main.go b/plugins/testdata/test-kvstore/main.go new file mode 100644 index 000000000..1816c2d2b --- /dev/null +++ b/plugins/testdata/test-kvstore/main.go @@ -0,0 +1,105 @@ +// Test KVStore plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-kvstore.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + pdk "github.com/extism/go-pdk" +) + +// TestKVStoreInput is the input for nd_test_kvstore callback. +type TestKVStoreInput struct { + Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used" + Key string `json:"key"` // Storage key + Value []byte `json:"value"` // For set operations + Prefix string `json:"prefix"` // For list operation +} + +// TestKVStoreOutput is the output from nd_test_kvstore callback. +type TestKVStoreOutput struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Keys []string `json:"keys,omitempty"` + StorageUsed int64 `json:"storage_used,omitempty"` + Error *string `json:"error,omitempty"` +} + +// nd_test_kvstore is the test callback that tests the kvstore host functions. +// +//go:wasmexport nd_test_kvstore +func ndTestKVStore() int32 { + var input TestKVStoreInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + + switch input.Operation { + case "set": + _, err := KVStoreSet(input.Key, input.Value) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{}) + return 0 + + case "get": + resp, err := KVStoreGet(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{Value: resp.Value, Exists: resp.Exists}) + return 0 + + case "delete": + _, err := KVStoreDelete(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{}) + return 0 + + case "has": + resp, err := KVStoreHas(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{Exists: resp.Exists}) + return 0 + + case "list": + resp, err := KVStoreList(input.Prefix) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{Keys: resp.Keys}) + return 0 + + case "get_storage_used": + resp, err := KVStoreGetStorageUsed() + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestKVStoreOutput{StorageUsed: resp.Bytes}) + return 0 + + default: + errStr := "unknown operation: " + input.Operation + pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) + return 0 + } +} + +func main() {} diff --git a/plugins/testdata/test-kvstore/manifest.json b/plugins/testdata/test-kvstore/manifest.json new file mode 100644 index 000000000..d2a411d93 --- /dev/null +++ b/plugins/testdata/test-kvstore/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "Test KVStore Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test kvstore plugin for integration testing", + "permissions": { + "kvstore": { + "reason": "For testing kvstore operations", + "maxSize": "10KB" + } + } +} diff --git a/plugins/testdata/test-kvstore/nd_host_kvstore.go b/plugins/testdata/test-kvstore/nd_host_kvstore.go new file mode 100644 index 000000000..a59028a6b --- /dev/null +++ b/plugins/testdata/test-kvstore/nd_host_kvstore.go @@ -0,0 +1,336 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the KVStore host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// kvstore_set is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_set +func kvstore_set(uint64) uint64 + +// kvstore_get is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_get +func kvstore_get(uint64) uint64 + +// kvstore_delete is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_delete +func kvstore_delete(uint64) uint64 + +// kvstore_has is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_has +func kvstore_has(uint64) uint64 + +// kvstore_list is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_list +func kvstore_list(uint64) uint64 + +// kvstore_getstorageused is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user kvstore_getstorageused +func kvstore_getstorageused(uint64) uint64 + +// KVStoreSetRequest is the request type for KVStore.Set. +type KVStoreSetRequest struct { + Key string `json:"key"` + Value []byte `json:"value"` +} + +// KVStoreSetResponse is the response type for KVStore.Set. +type KVStoreSetResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreGetRequest is the request type for KVStore.Get. +type KVStoreGetRequest struct { + Key string `json:"key"` +} + +// KVStoreGetResponse is the response type for KVStore.Get. +type KVStoreGetResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreDeleteRequest is the request type for KVStore.Delete. +type KVStoreDeleteRequest struct { + Key string `json:"key"` +} + +// KVStoreDeleteResponse is the response type for KVStore.Delete. +type KVStoreDeleteResponse struct { + Error string `json:"error,omitempty"` +} + +// KVStoreHasRequest is the request type for KVStore.Has. +type KVStoreHasRequest struct { + Key string `json:"key"` +} + +// KVStoreHasResponse is the response type for KVStore.Has. +type KVStoreHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreListRequest is the request type for KVStore.List. +type KVStoreListRequest struct { + Prefix string `json:"prefix"` +} + +// KVStoreListResponse is the response type for KVStore.List. +type KVStoreListResponse struct { + Keys []string `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed. +type KVStoreGetStorageUsedResponse struct { + Bytes int64 `json:"bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// KVStoreSet calls the kvstore_set host function. +// Set stores a byte value with the given key. +// +// Parameters: +// - key: The storage key (max 256 bytes, UTF-8) +// - value: The byte slice to store +// +// Returns an error if the storage limit would be exceeded or the operation fails. +func KVStoreSet(key string, value []byte) (*KVStoreSetResponse, error) { + // Marshal request to JSON + req := KVStoreSetRequest{ + Key: key, + Value: value, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_set(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreSetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreGet calls the kvstore_get host function. +// Get retrieves a byte value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns the value and whether the key exists. +func KVStoreGet(key string) (*KVStoreGetResponse, error) { + // Marshal request to JSON + req := KVStoreGetRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_get(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreGetResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreDelete calls the kvstore_delete host function. +// Delete removes a value from storage. +// +// Parameters: +// - key: The storage key +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func KVStoreDelete(key string) (*KVStoreDeleteResponse, error) { + // Marshal request to JSON + req := KVStoreDeleteRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_delete(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreDeleteResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreHas calls the kvstore_has host function. +// Has checks if a key exists in storage. +// +// Parameters: +// - key: The storage key +// +// Returns true if the key exists. +func KVStoreHas(key string) (*KVStoreHasResponse, error) { + // Marshal request to JSON + req := KVStoreHasRequest{ + Key: key, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_has(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreHasResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreList calls the kvstore_list host function. +// List returns all keys matching the given prefix. +// +// Parameters: +// - prefix: Key prefix to filter by (empty string returns all keys) +// +// Returns a slice of matching keys. +func KVStoreList(prefix string) (*KVStoreListResponse, error) { + // Marshal request to JSON + req := KVStoreListRequest{ + Prefix: prefix, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_list(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreListResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +} + +// KVStoreGetStorageUsed calls the kvstore_getstorageused host function. +// GetStorageUsed returns the total storage used by this plugin in bytes. +func KVStoreGetStorageUsed() (*KVStoreGetStorageUsedResponse, error) { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := kvstore_getstorageused(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response KVStoreGetStorageUsedResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return &response, nil +}