Add namespace-scoped event stores for storage isolation
All checks were successful
CI / build (pull_request) Successful in 15s
CI / build (push) Successful in 16s

Add support for optional namespace prefixes on JetStreamEventStore streams
to enable complete namespace isolation at the storage level:

- Add Namespace field to JetStreamConfig
- Add NewJetStreamEventStoreWithNamespace convenience constructor
- Prefix stream names with sanitized namespace when configured
- Add GetNamespace and GetStreamName accessor methods
- Add unit tests for namespace functionality
- Document namespace-scoped stores in CLAUDE.md

The namespace prefix is sanitized (spaces, dots, wildcards converted to
underscores) and prepended to the stream name, ensuring events from one
namespace cannot be read from another namespace's store while maintaining
full backward compatibility for non-namespaced stores.

Closes #19

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit was merged in pull request #48.
This commit is contained in:
2026-01-10 19:01:03 +01:00
parent 484e3ced2e
commit f62964bf3b
3 changed files with 192 additions and 4 deletions

124
store/namespace_test.go Normal file
View File

@@ -0,0 +1,124 @@
package store
import (
"testing"
)
func TestJetStreamConfigNamespace(t *testing.T) {
t.Run("default config has empty namespace", func(t *testing.T) {
config := DefaultJetStreamConfig()
if config.Namespace != "" {
t.Errorf("expected empty namespace in default config, got %q", config.Namespace)
}
})
t.Run("namespace can be set in config", func(t *testing.T) {
config := JetStreamConfig{
Namespace: "tenant-abc",
}
if config.Namespace != "tenant-abc" {
t.Errorf("expected namespace tenant-abc, got %q", config.Namespace)
}
})
}
func TestNamespacedStreamName(t *testing.T) {
tests := []struct {
name string
baseStreamName string
namespace string
expectedStreamName string
}{
{
name: "no namespace - stream name unchanged",
baseStreamName: "events",
namespace: "",
expectedStreamName: "events",
},
{
name: "with namespace - prefixed stream name",
baseStreamName: "events",
namespace: "tenant-abc",
expectedStreamName: "tenant-abc_events",
},
{
name: "namespace with dots - sanitized",
baseStreamName: "events",
namespace: "tenant.abc",
expectedStreamName: "tenant_abc_events",
},
{
name: "namespace with spaces - sanitized",
baseStreamName: "events",
namespace: "tenant abc",
expectedStreamName: "tenant_abc_events",
},
{
name: "namespace with special chars - sanitized",
baseStreamName: "events",
namespace: "tenant*abc>def",
expectedStreamName: "tenant_abc_def_events",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// We can't create a real JetStreamEventStore without NATS,
// but we can test the stream name logic by examining the expected format
effectiveStreamName := tt.baseStreamName
if tt.namespace != "" {
effectiveStreamName = sanitizeSubject(tt.namespace) + "_" + tt.baseStreamName
}
if effectiveStreamName != tt.expectedStreamName {
t.Errorf("expected stream name %q, got %q", tt.expectedStreamName, effectiveStreamName)
}
})
}
}
func TestSanitizeSubject(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"simple", "simple"},
{"with spaces", "with_spaces"},
{"with.dots", "with_dots"},
{"with*stars", "with_stars"},
{"with>greater", "with_greater"},
{"complex.name with*special>chars", "complex_name_with_special_chars"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := sanitizeSubject(tt.input)
if result != tt.expected {
t.Errorf("sanitizeSubject(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestExtractActorType(t *testing.T) {
tests := []struct {
actorID string
expectedType string
}{
{"order-123", "order"},
{"user-abc-def", "user"},
{"nodelimiter", "unknown"},
{"", "unknown"},
{"-leadingdash", "unknown"},
{"a-b", "a"},
}
for _, tt := range tests {
t.Run(tt.actorID, func(t *testing.T) {
result := extractActorType(tt.actorID)
if result != tt.expectedType {
t.Errorf("extractActorType(%q) = %q, want %q", tt.actorID, result, tt.expectedType)
}
})
}
}