Feat v0.8.0 files module (#67)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 1m20s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #67.
This commit is contained in:
2026-04-03 00:18:05 +00:00
committed by xcaliber
parent 3b74774077
commit 694779fac6
13 changed files with 1169 additions and 5 deletions

View File

@@ -211,6 +211,78 @@ func (s *PVCStore) Exists(ctx context.Context, key string) (bool, error) {
return false, fmt.Errorf("storage/pvc: exists %q: %w", key, err)
}
// List returns objects under the given prefix, up to limit entries.
func (s *PVCStore) List(ctx context.Context, prefix string, limit int) ([]ObjectEntry, error) {
if prefix != "" {
if err := validateKey(prefix); err != nil {
return nil, err
}
}
if limit <= 0 {
limit = 100
}
absPrefix := s.resolve(prefix)
// If the prefix path doesn't exist, return empty.
info, err := os.Stat(absPrefix)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("storage/pvc: list %q: %w", prefix, err)
}
var entries []ObjectEntry
if !info.IsDir() {
// Prefix points to a single file — return it.
s.mu.RLock()
ct := s.mimeMap[prefix]
s.mu.RUnlock()
if ct == "" {
ct = "application/octet-stream"
}
return []ObjectEntry{{Key: prefix, Size: info.Size(), ContentType: ct}}, nil
}
// Walk the directory tree under the prefix.
walkRoot := absPrefix
err = filepath.Walk(walkRoot, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil {
return nil // skip inaccessible entries
}
if fi.IsDir() {
return nil
}
if len(entries) >= limit {
return filepath.SkipAll
}
// Recover the storage key by stripping basePath + "/".
rel, relErr := filepath.Rel(s.basePath, path)
if relErr != nil {
return nil
}
key := filepath.ToSlash(rel)
s.mu.RLock()
ct := s.mimeMap[key]
s.mu.RUnlock()
if ct == "" {
ct = "application/octet-stream"
}
entries = append(entries, ObjectEntry{Key: key, Size: fi.Size(), ContentType: ct})
return nil
})
if err != nil {
return entries, fmt.Errorf("storage/pvc: list walk %q: %w", prefix, err)
}
return entries, nil
}
// Healthy verifies the storage path is writable.
func (s *PVCStore) Healthy(ctx context.Context) error {
probe := filepath.Join(s.basePath, ".health-probe")

View File

@@ -282,6 +282,64 @@ func TestPVC_SubdirCreation(t *testing.T) {
}
}
func TestPVC_List(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
// Empty prefix — no files.
entries, err := s.List(ctx, "ext/pkg1/", 100)
if err != nil {
t.Fatalf("List empty: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
}
// Add some files under a prefix.
for _, name := range []string{"a.txt", "b.png", "sub/c.pdf"} {
key := "ext/pkg1/" + name
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
}
// Add a file under a different prefix.
_ = s.Put(ctx, "ext/pkg2/other.txt", bytes.NewReader([]byte("y")), 1, "text/plain")
// List all under pkg1.
entries, err = s.List(ctx, "ext/pkg1/", 100)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 3 {
t.Errorf("expected 3 entries, got %d", len(entries))
}
// List with limit.
entries, err = s.List(ctx, "ext/pkg1/", 2)
if err != nil {
t.Fatalf("List limited: %v", err)
}
if len(entries) != 2 {
t.Errorf("expected 2 entries (limit), got %d", len(entries))
}
// List with sub-prefix.
entries, err = s.List(ctx, "ext/pkg1/sub/", 100)
if err != nil {
t.Fatalf("List sub: %v", err)
}
if len(entries) != 1 {
t.Errorf("expected 1 entry in sub/, got %d", len(entries))
}
// pkg2 prefix should only find 1.
entries, err = s.List(ctx, "ext/pkg2/", 100)
if err != nil {
t.Fatalf("List pkg2: %v", err)
}
if len(entries) != 1 {
t.Errorf("expected 1 entry in pkg2, got %d", len(entries))
}
}
func TestNewPVC_InvalidPath(t *testing.T) {
// Path that can't be created (nested under a file)
tmp := t.TempDir()

View File

@@ -232,6 +232,49 @@ func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) {
return true, nil
}
// List returns objects under the given prefix, up to limit entries.
func (s *S3Store) List(ctx context.Context, prefix string, limit int) ([]ObjectEntry, error) {
if prefix != "" {
if err := validateKey(prefix); err != nil {
return nil, err
}
}
if limit <= 0 {
limit = 100
}
fullPrefix := s.fullKey(prefix)
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
Prefix: fullPrefix,
Recursive: true,
})
var entries []ObjectEntry
for obj := range objectsCh {
if obj.Err != nil {
return entries, fmt.Errorf("storage/s3: list %q: %w", prefix, obj.Err)
}
if len(entries) >= limit {
// Drain the remaining channel entries (minio requires it).
for range objectsCh {
}
break
}
// Strip the s3 prefix to recover the storage key.
key := strings.TrimPrefix(obj.Key, s.prefix)
ct := obj.ContentType
if ct == "" {
ct = "application/octet-stream"
}
entries = append(entries, ObjectEntry{Key: key, Size: obj.Size, ContentType: ct})
}
return entries, nil
}
// Healthy checks connectivity by performing a HeadBucket.
func (s *S3Store) Healthy(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)

View File

@@ -56,10 +56,21 @@ type ObjectStore interface {
// Stats returns aggregate storage statistics.
Stats(ctx context.Context) (*StorageStats, error)
// List returns objects under the given prefix, up to limit entries.
// Returns an empty slice (not error) if no objects match.
List(ctx context.Context, prefix string, limit int) ([]ObjectEntry, error)
// Backend returns the backend type identifier ("pvc", "s3").
Backend() string
}
// ObjectEntry represents a single object returned by List.
type ObjectEntry struct {
Key string `json:"key"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
}
// StorageStats holds aggregate storage metrics.
type StorageStats struct {
Backend string `json:"backend"`