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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ GOARCH=wasm GOOS=wasip1 go build -o plugin.wasm main.go
### `Context`
The bridge between your plugin and the Paca host. Available via `Init(ctx)`:
- `ctx.DB()` — access the scoped database (PostgreSQL)
- `ctx.KV()` — access the plugin-private key-value store (JSONB)
- `ctx.KV()` — access the plugin-private key-value store (JSONB), durable with no expiry
- `ctx.Cache()` — access the plugin's TTL-based cache (Valkey/Redis), for derived data that can tolerate being briefly stale
- `ctx.Log()` — write structured logs to the host
- `ctx.Config()` — read plugin configuration and secrets
- `ctx.Route(method, path, handler)` — register an HTTP endpoint
Expand Down
45 changes: 45 additions & 0 deletions backends.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package plugin

import "time"

// ── DB ────────────────────────────────────────────────────────────────────────

// DBBackend is the interface implemented by the WASM host runtime and test
Expand Down Expand Up @@ -56,6 +58,49 @@ func (k *KV) Set(key, value string) { k.backend.Set(key, value) }
// Delete removes key from the store.
func (k *KV) Delete(key string) { k.backend.Delete(key) }

// ── Cache ─────────────────────────────────────────────────────────────────────

// CacheBackend is the interface implemented by the WASM host runtime and test
// stubs for the plugin's Valkey/Redis-backed cache.
type CacheBackend interface {
Get(key string) (string, bool)
Set(key, value string, ttl time.Duration)
Delete(key string)
}

// Cache is a string key-value store backed by the host's shared Valkey/Redis
// instance, namespaced per plugin so different plugins' keys never collide.
// Unlike KV (backed by Postgres, durable, no expiry), entries here expire
// after their TTL — use it for derived/recomputable data (e.g. expensive
// query results) rather than authoritative state.
type Cache struct {
backend CacheBackend
}

// Get retrieves the value for key. Returns ("", false) on a cache miss,
// including when the entry has expired.
func (c *Cache) Get(key string) (string, bool) { return c.backend.Get(key) }

// Set stores value under key for the given TTL. A zero TTL stores the value
// without expiry.
func (c *Cache) Set(key, value string, ttl time.Duration) { c.backend.Set(key, value, ttl) }

// Delete removes key from the cache.
func (c *Cache) Delete(key string) { c.backend.Delete(key) }

// ttlToSeconds converts ttl to whole seconds for backends (such as the WASM
// host cache_set import) that only accept second-granularity TTLs. It rounds
// up rather than truncating, so any positive sub-second ttl still maps to a
// positive number of seconds instead of silently becoming 0 — which the host
// treats as "store without expiry", turning a short-lived cache entry into a
// permanent one. Non-positive durations map to 0.
func ttlToSeconds(ttl time.Duration) int32 {
if ttl <= 0 {
return 0
}
return int32((ttl + time.Second - 1) / time.Second)
}

// ── Logger ────────────────────────────────────────────────────────────────────

// LogBackend is the interface used by [Logger] to emit log messages.
Expand Down
33 changes: 33 additions & 0 deletions backends_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package plugin

import (
"testing"
"time"
)

// ttlToSeconds feeds the WASM host's cache_set import, which only accepts
// whole seconds and treats 0 as "store without expiry". These cases guard
// against a sub-second ttl silently truncating to 0 and turning a
// short-lived cache entry into a permanent one.
func TestTTLToSeconds(t *testing.T) {
cases := []struct {
name string
ttl time.Duration
want int32
}{
{"zero", 0, 0},
{"negative", -time.Second, 0},
{"sub-second rounds up to one second, not zero", 500 * time.Millisecond, 1},
{"just under a second rounds up", 999 * time.Millisecond, 1},
{"exact second stays exact", time.Second, 1},
{"partial second past a whole second rounds up", 1500 * time.Millisecond, 2},
{"minutes", 5 * time.Minute, 300},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ttlToSeconds(tc.ttl); got != tc.want {
t.Fatalf("ttlToSeconds(%v) = %d, want %d", tc.ttl, got, tc.want)
}
})
}
}
9 changes: 8 additions & 1 deletion context.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Context struct {
events map[string]EventHandler
db *DB
kv *KV
cache *Cache
log *Logger
cfg *Config
perm *Permissions
Expand Down Expand Up @@ -46,6 +47,11 @@ func (c *Context) DB() *DB { return c.db }
// KV returns a helper for simple key-value persistence.
func (c *Context) KV() *KV { return c.kv }

// Cache returns a helper for the plugin's TTL-based Valkey/Redis-backed
// cache — use it for expensive-to-recompute data that can tolerate being
// briefly stale, as opposed to KV's durable, non-expiring storage.
func (c *Context) Cache() *Cache { return c.cache }

// Log returns a structured logger.
func (c *Context) Log() *Logger { return c.log }

Expand All @@ -65,12 +71,13 @@ type EventHandler func(evt *Event)
// newContext constructs a Context backed by the provided implementations.
// Called by the WASM runtime (with host-function backends) and by
// [plugintest] (with in-memory backends).
func newContext(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context {
func newContext(db DBBackend, kv KVBackend, cache CacheBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context {
return &Context{
routes: make(map[routeKey]RouteHandler),
events: make(map[string]EventHandler),
db: &DB{backend: db},
kv: &KV{backend: kv},
cache: &Cache{backend: cache},
log: &Logger{backend: log},
cfg: &Config{backend: cfg},
perm: &Permissions{backend: perm},
Expand Down
4 changes: 2 additions & 2 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import "testing"
// literal ":panelId" pattern string as the path, so it never exercised this
// branch — this test calls matchRoute with a real resolved path instead.
func TestMatchRoute_PrefersLiteralSegmentOverWildcard(t *testing.T) {
ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend())
ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMCacheBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend())

var gotByPanelID, gotLayout bool
ctx.Route("PATCH", "/views/:viewId/panels/:panelId", func(_ *Request, _ *Response) {
Expand Down Expand Up @@ -61,7 +61,7 @@ func TestMatchRoute_PrefersLiteralSegmentOverWildcard(t *testing.T) {
// iteration order; after stripping, the wildcard pattern scores 1 and the
// literal one correctly and deterministically wins.
func TestMatchRoute_PrefersLiteralOverWildcardAcrossRegistrationStyles(t *testing.T) {
ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend())
ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMCacheBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend())

var gotWildcard, gotLiteral bool
ctx.Route("GET", "/projects/:projectId/tasks/:taskId", func(_ *Request, _ *Response) {
Expand Down
1 change: 1 addition & 0 deletions dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (d *dispatcher) init() error {
d.ctx = newContext(
newWASMDBBackend(),
newWASMKVBackend(),
newWASMCacheBackend(),
newWASMLogBackend(),
newWASMConfigBackend(),
newWASMPermissionBackend(),
Expand Down
21 changes: 16 additions & 5 deletions native_backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

// Package plugin — stub backends for non-WASM builds.
//
// These stubs satisfy the DBBackend/KVBackend/LogBackend/ConfigBackend
// interfaces and the EmitEvent function when compiling for native platforms
// (e.g. go test, go vet). They are intentionally no-ops / error-returners
// so that tests must inject real implementations via plugintest.NewContext().
// These stubs satisfy the DBBackend/KVBackend/CacheBackend/LogBackend/
// ConfigBackend interfaces and the EmitEvent function when compiling for
// native platforms (e.g. go test, go vet). They are intentionally no-ops /
// error-returners so that tests must inject real implementations via
// plugintest.NewContext().
package plugin

import "errors"
import (
"errors"
"time"
)

var errNotWASM = errors.New("plugin: this function is only available in a WASM module")

// ── Stubs ─────────────────────────────────────────────────────────────────────

func newWASMDBBackend() DBBackend { return &stubDBBackend{} }
func newWASMKVBackend() KVBackend { return &stubKVBackend{} }
func newWASMCacheBackend() CacheBackend { return &stubCacheBackend{} }
func newWASMLogBackend() LogBackend { return &stubLogBackend{} }
func newWASMConfigBackend() ConfigBackend { return &stubConfigBackend{} }
func newWASMPermissionBackend() PermissionBackend { return &stubPermissionBackend{} }
Expand All @@ -35,6 +40,12 @@ func (b *stubKVBackend) Get(_ string) (string, bool) { return "", false }
func (b *stubKVBackend) Set(_, _ string) {}
func (b *stubKVBackend) Delete(_ string) {}

type stubCacheBackend struct{}

func (b *stubCacheBackend) Get(_ string) (string, bool) { return "", false }
func (b *stubCacheBackend) Set(_, _ string, _ time.Duration) {}
func (b *stubCacheBackend) Delete(_ string) {}

type stubLogBackend struct{}

func (b *stubLogBackend) Log(_ int, _ string) {}
Expand Down
63 changes: 63 additions & 0 deletions plugintest/backends.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"sync"
"time"

plugin "github.com/Paca-AI/plugin-sdk-go"
)
Expand Down Expand Up @@ -514,6 +515,68 @@ func (k *InMemoryKV) Delete(key string) {
delete(k.data, key)
}

// ── InMemoryCache ─────────────────────────────────────────────────────────────

// InMemoryCache is a map-backed plugin.CacheBackend for tests, simulating
// TTL expiry against a fake clock instead of a real Valkey/Redis instance.
type InMemoryCache struct {
mu sync.Mutex
data map[string]cacheEntry
now func() time.Time
}

type cacheEntry struct {
value string
expireAt time.Time // zero means no expiry
}

func newInMemoryCache() *InMemoryCache {
return &InMemoryCache{data: make(map[string]cacheEntry), now: time.Now}
}

// Get implements plugin.CacheBackend. Returns ("", false) on a miss or when
// the entry's TTL has elapsed (per the fake clock set via Advance).
func (c *InMemoryCache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.data[key]
if !ok {
return "", false
}
if !entry.expireAt.IsZero() && !c.now().Before(entry.expireAt) {
delete(c.data, key)
return "", false
}
return entry.value, true
}

// Set implements plugin.CacheBackend. A zero ttl stores the value without expiry.
func (c *InMemoryCache) Set(key, value string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
var expireAt time.Time
if ttl > 0 {
expireAt = c.now().Add(ttl)
}
c.data[key] = cacheEntry{value: value, expireAt: expireAt}
}

// Delete implements plugin.CacheBackend.
func (c *InMemoryCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}

// Advance moves the fake clock forward by d, so tests can assert that an
// entry expires after its TTL without a real sleep.
func (c *InMemoryCache) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
base := c.now()
c.now = func() time.Time { return base.Add(d) }
}

// ── CapturingLogger ───────────────────────────────────────────────────────────

// LogEntry is a single captured log message.
Expand Down
52 changes: 51 additions & 1 deletion plugintest/backends_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package plugintest

import "testing"
import (
"testing"
"time"
)

// These tests cover InMemoryDB's WHERE-clause evaluation directly (SELECT,
// UPDATE, DELETE), since a regression here silently produces false negatives
Expand Down Expand Up @@ -84,3 +87,50 @@ func TestInMemoryDB_Delete_DoesNotMatchOtherRowsSharingOneCondition(t *testing.T
t.Fatalf("expected no rows deleted (id/owner mismatch), got %+v", remaining)
}
}

func TestInMemoryCache_GetMiss(t *testing.T) {
c := newInMemoryCache()
if _, ok := c.Get("missing"); ok {
t.Fatal("expected miss for a key that was never set")
}
}

func TestInMemoryCache_SetThenGet(t *testing.T) {
c := newInMemoryCache()
c.Set("k", "v", time.Minute)
got, ok := c.Get("k")
if !ok || got != "v" {
t.Fatalf("expected hit with value %q, got (%q, %v)", "v", got, ok)
}
}

func TestInMemoryCache_ZeroTTLNeverExpires(t *testing.T) {
c := newInMemoryCache()
c.Set("k", "v", 0)
c.Advance(24 * time.Hour)
if _, ok := c.Get("k"); !ok {
t.Fatal("expected a zero-TTL entry to survive any amount of elapsed time")
}
}

func TestInMemoryCache_ExpiresAfterTTL(t *testing.T) {
c := newInMemoryCache()
c.Set("k", "v", 5*time.Minute)
c.Advance(4 * time.Minute)
if _, ok := c.Get("k"); !ok {
t.Fatal("expected entry to still be present before its TTL elapses")
}
c.Advance(2 * time.Minute) // total 6m > 5m TTL
if _, ok := c.Get("k"); ok {
t.Fatal("expected entry to be gone after its TTL elapses")
}
}

func TestInMemoryCache_Delete(t *testing.T) {
c := newInMemoryCache()
c.Set("k", "v", time.Minute)
c.Delete("k")
if _, ok := c.Get("k"); ok {
t.Fatal("expected key to be gone after Delete")
}
}
6 changes: 5 additions & 1 deletion plugintest/plugintest.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type Context struct {
DB *InMemoryDB
// KV is the in-memory key-value store.
KV *InMemoryKV
// Cache is the in-memory TTL cache store.
Cache *InMemoryCache
// Log captures log messages emitted by the plugin.
Log *CapturingLogger
// Config is an in-memory config store.
Expand All @@ -60,16 +62,18 @@ func NewContext(t testing.TB) *Context {
t.Helper()
db := newInMemoryDB()
kv := newInMemoryKV()
cache := newInMemoryCache()
log := newCapturingLogger()
cfg := newInMemoryConfig()
perm := newFakePermissions()

pCtx := plugin.NewContextForTest(db, kv, log, cfg, perm)
pCtx := plugin.NewContextForTest(db, kv, cache, log, cfg, perm)
d := &testDispatcher{pluginCtx: pCtx}

tc := &Context{
DB: db,
KV: kv,
Cache: cache,
Log: log,
Config: cfg,
Permissions: perm,
Expand Down
4 changes: 2 additions & 2 deletions testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ package plugin
//
// Production code should use [Run], which installs the WASM host-function
// backends automatically. This function is safe to call from any build target.
func NewContextForTest(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context {
return newContext(db, kv, log, cfg, perm)
func NewContextForTest(db DBBackend, kv KVBackend, cache CacheBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context {
return newContext(db, kv, cache, log, cfg, perm)
}

// DispatchRoute calls the handler registered at method+path in ctx and writes
Expand Down
Loading
Loading