mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
feat(plugins): implement KVStore service for persistent key-value storage
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
4e392f7b07
commit
2b2bc5dcb2
@ -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.
|
||||
|
||||
336
plugins/host/go/nd_host_kvstore.go
Normal file
336
plugins/host/go/nd_host_kvstore.go
Normal file
@ -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
|
||||
}
|
||||
65
plugins/host/kvstore.go
Normal file
65
plugins/host/kvstore.go
Normal file
@ -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)
|
||||
}
|
||||
297
plugins/host/kvstore_gen.go
Normal file
297
plugins/host/kvstore_gen.go
Normal file
@ -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
|
||||
}
|
||||
241
plugins/host/python/nd_host_kvstore.py
Normal file
241
plugins/host/python/nd_host_kvstore.py
Normal file
@ -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)
|
||||
250
plugins/host_kvstore.go
Normal file
250
plugins/host_kvstore.go
Normal file
@ -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)
|
||||
582
plugins/host_kvstore_test.go
Normal file
582
plugins/host_kvstore_test.go
Normal file
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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"`
|
||||
|
||||
|
||||
5
plugins/testdata/test-kvstore/go.mod
vendored
Normal file
5
plugins/testdata/test-kvstore/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
module test-kvstore
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/testdata/test-kvstore/go.sum
vendored
Normal file
2
plugins/testdata/test-kvstore/go.sum
vendored
Normal file
@ -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=
|
||||
105
plugins/testdata/test-kvstore/main.go
vendored
Normal file
105
plugins/testdata/test-kvstore/main.go
vendored
Normal file
@ -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() {}
|
||||
12
plugins/testdata/test-kvstore/manifest.json
vendored
Normal file
12
plugins/testdata/test-kvstore/manifest.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
336
plugins/testdata/test-kvstore/nd_host_kvstore.go
vendored
Normal file
336
plugins/testdata/test-kvstore/nd_host_kvstore.go
vendored
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user