migrate orion repo into monorepo structure

This commit is contained in:
talksik
2026-02-21 08:48:34 -08:00
parent 144596fcaa
commit b5f90709de
91 changed files with 19775 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
package particle
import "errors"
var (
ErrNotFound = errors.New("particle not found")
ErrAccessDenied = errors.New("access denied")
ErrCapacityExceeded = errors.New("open stream capacity exceeded")
ErrInvalidParent = errors.New("invalid parent particle")
ErrInvalidType = errors.New("invalid particle type")
ErrInvalidData = errors.New("invalid particle data")
ErrNotAStream = errors.New("particle is not a stream")
ErrStreamAlreadyOpen = errors.New("stream is already open")
ErrStreamAlreadyClosed = errors.New("stream is already closed")
ErrAccessExpansion = errors.New("cannot expand access beyond parent")
ErrMembersRequired = errors.New("custom visibility requires at least one member")
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
ErrNotAContainer = errors.New("only streams can have members")
)
+174
View File
@@ -0,0 +1,174 @@
package particle
import (
"encoding/json"
"errors"
"time"
)
// ParticleType represents the type of particle
type ParticleType string
const (
TypeStream ParticleType = "stream"
TypeFolder ParticleType = "folder"
TypeMedia ParticleType = "media"
TypeFile ParticleType = "file"
TypeText ParticleType = "text"
TypeQuest ParticleType = "quest"
TypePaper ParticleType = "paper"
// TypeThink ParticleType = "think"
)
// VisibilityMode represents how access to a particle is determined
type VisibilityMode string
const (
VisibilityNetworkAll VisibilityMode = "network_all"
VisibilityCustom VisibilityMode = "custom"
VisibilityInherited VisibilityMode = "inherited"
)
var ErrInvalidParticleType = errors.New("invalid particle type")
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
// ParseParticleType parses a string into a ParticleType
func ParseParticleType(s string) (ParticleType, error) {
switch s {
case string(TypeStream):
return TypeStream, nil
case string(TypeFolder):
return TypeFolder, nil
case string(TypeMedia):
return TypeMedia, nil
case string(TypeFile):
return TypeFile, nil
case string(TypeText):
return TypeText, nil
case string(TypeQuest):
return TypeQuest, nil
case string(TypePaper):
return TypePaper, nil
default:
return "", ErrInvalidParticleType
}
}
// ParseVisibilityMode parses a string into a VisibilityMode
func ParseVisibilityMode(s string) (VisibilityMode, error) {
switch s {
case "", string(VisibilityNetworkAll):
return VisibilityNetworkAll, nil
case string(VisibilityCustom):
return VisibilityCustom, nil
case string(VisibilityInherited):
return VisibilityInherited, nil
default:
return "", ErrInvalidVisibilityMode
}
}
// Stream status values
type StreamStatus string
const (
StreamStatusOpen StreamStatus = "open"
StreamStatusClosed StreamStatus = "closed"
)
// Particle represents a content particle in the system
type Particle struct {
ID string
Type ParticleType
NetworkID string
ParentID *string
CreatedByEmail string
Visibility VisibilityMode
Data json.RawMessage
UpdatedAt time.Time
CreatedAt time.Time
}
// CreateInput represents the input for creating a new particle
type CreateInput struct {
Type ParticleType
NetworkID string
ParentID *string
Data json.RawMessage
Members []string // Only used when visibility is custom
Visibility VisibilityMode
}
// ListFilter represents filtering options for listing particles
type ListFilter struct {
Types []ParticleType
}
// Cursor represents a pagination cursor for bidirectional pagination
type Cursor struct {
Position string // particle ID or timestamp
Direction string // "before" or "after"
}
// ParticleList represents a paginated list of particles
type ParticleList struct {
Particles []*Particle
HasMore bool
NextCursor *Cursor
PrevCursor *Cursor
}
// StreamData represents the data stored for stream particles
type StreamData struct {
Name string `json:"name"`
Status string `json:"status"` // "open" or "closed"
Description *string `json:"description"`
}
// FolderData represents the data stored for folder particles
type FolderData struct {
Name string `json:"name"`
Color *string `json:"color"`
}
// MediaData represents the data stored for media particles
type MediaData struct {
ObjectID string `json:"object_id"` // reference to storage object
MimeType string `json:"mime_type"`
DurationMs int `json:"duration_ms"`
// Caption *string `json:"caption"`
}
// FileData represents the data stored for file particles
type FileData struct {
ObjectID string `json:"object_id"` // reference to storage object
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"` // in bytes
}
// TextData represents the data stored for text particles
type TextData struct {
Content string `json:"content"`
}
// QuestData represents the data stored for quest particles
type QuestData struct {
Title string `json:"title"`
Description string `json:"description"`
Status *string `json:"status"`
AssignedTo *string `json:"assigned_to,omitempty"` // email
DueDate *string `json:"due_date,omitempty"` // ISO date string
}
// PaperData represents the data stored for paper particles
type PaperData struct {
Title string `json:"title"`
Content string `json:"content"` // markdown
}
// AckInfo represents an acknowledgment record
type AckInfo struct {
Email string
AckedAt time.Time
}
+413
View File
@@ -0,0 +1,413 @@
package particle
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"go.jetify.com/typeid"
)
var errNotFound = errors.New("not found")
var errAccessDenied = errors.New("access denied")
type particleIDPrefix struct{}
func (particleIDPrefix) Prefix() string { return "particle" }
type particleID struct {
typeid.TypeID[particleIDPrefix]
}
func newParticleID() (particleID, error) {
return typeid.New[particleID]()
}
type repository interface {
create(ctx context.Context, p *Particle) (*Particle, error)
getByID(ctx context.Context, id string) (*Particle, error)
update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error
delete(ctx context.Context, id string) error
list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error)
setVisibility(ctx context.Context, id string, mode VisibilityMode) error
addMembers(ctx context.Context, particleID string, emails []string) error
removeMembers(ctx context.Context, particleID string, emails []string) error
getMembers(ctx context.Context, particleID string) ([]string, error)
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
// getAncestorChain returns the particle and all its ancestors (for access checks)
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
// Seen tracking
markSeen(ctx context.Context, particleID, email string) error
getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error)
getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error)
// Ack tracking
ack(ctx context.Context, particleID, email string) error
getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) create(ctx context.Context, p *Particle) (*Particle, error) {
id, err := newParticleID()
if err != nil {
return nil, err
}
var result Particle
err = r.pool.QueryRow(ctx,
`INSERT INTO particles (id, type, network_id, parent_id, created_by_email, visibility, data)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at`,
id.String(), p.Type, p.NetworkID, p.ParentID, p.CreatedByEmail, p.Visibility, p.Data,
).Scan(&result.ID, &result.Type, &result.NetworkID, &result.ParentID, &result.CreatedByEmail,
&result.Visibility, &result.Data, &result.UpdatedAt, &result.CreatedAt)
if err != nil {
return nil, err
}
return &result, nil
}
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Particle, error) {
var p Particle
err := r.pool.QueryRow(ctx,
`SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
FROM particles WHERE id = $1`,
id,
).Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
return nil, err
}
return &p, nil
}
func (r *repositoryImpl) update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error {
result, err := r.pool.Exec(ctx,
`UPDATE particles SET data = $1, updated_at = $2 WHERE id = $3`,
data, updatedAt, id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) delete(ctx context.Context, id string) error {
result, err := r.pool.Exec(ctx, `DELETE FROM particles WHERE id = $1`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error) {
query := `SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
FROM particles p WHERE p.network_id = $1`
args := []any{networkID}
argIdx := 2
if parentID != nil {
query += ` AND p.parent_id = $` + string(rune('0'+argIdx))
args = append(args, *parentID)
argIdx++
} else {
query += ` AND p.parent_id IS NULL`
}
// Filter by visibility: include if network_all, inherited, OR user is a member
query += ` AND (p.visibility = 'network_all' OR p.visibility = 'inherited' OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $` + string(rune('0'+argIdx)) + `))`
args = append(args, requesterEmail)
argIdx++
if len(filter.Types) > 0 {
query += ` AND p.type = ANY($` + string(rune('0'+argIdx)) + `)`
typeStrings := make([]string, len(filter.Types))
for i, t := range filter.Types {
typeStrings[i] = string(t)
}
args = append(args, typeStrings)
argIdx++
}
if cursor != nil {
if cursor.Direction == "before" {
query += ` AND p.updated_at > $` + string(rune('0'+argIdx))
} else {
query += ` AND p.updated_at < $` + string(rune('0'+argIdx))
}
args = append(args, cursor.Position)
argIdx++
}
query += ` ORDER BY p.updated_at DESC LIMIT $` + string(rune('0'+argIdx))
args = append(args, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanParticles(rows)
}
func scanParticles(rows pgx.Rows) ([]*Particle, error) {
var particles []*Particle
for rows.Next() {
var p Particle
if err := rows.Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt); err != nil {
return nil, err
}
particles = append(particles, &p)
}
return particles, rows.Err()
}
func (r *repositoryImpl) setVisibility(ctx context.Context, id string, mode VisibilityMode) error {
result, err := r.pool.Exec(ctx,
`UPDATE particles SET visibility = $1, updated_at = NOW() WHERE id = $2`,
mode, id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) addMembers(ctx context.Context, particleID string, emails []string) error {
for _, email := range emails {
_, err := r.pool.Exec(ctx,
`INSERT INTO particle_members (particle_id, email) VALUES ($1, $2)
ON CONFLICT (particle_id, email) DO NOTHING`,
particleID, email,
)
if err != nil {
return err
}
}
return nil
}
func (r *repositoryImpl) removeMembers(ctx context.Context, particleID string, emails []string) error {
for _, email := range emails {
_, err := r.pool.Exec(ctx,
`DELETE FROM particle_members WHERE particle_id = $1 AND email = $2`,
particleID, email,
)
if err != nil {
return err
}
}
return nil
}
func (r *repositoryImpl) getMembers(ctx context.Context, particleID string) ([]string, error) {
rows, err := r.pool.Query(ctx,
`SELECT email FROM particle_members WHERE particle_id = $1`,
particleID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, rows.Err()
}
func (r *repositoryImpl) getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
if len(particleIDs) == 0 {
return map[string][]string{}, nil
}
rows, err := r.pool.Query(ctx,
`SELECT particle_id, email FROM particle_members WHERE particle_id = ANY($1)`,
particleIDs,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string][]string)
for rows.Next() {
var particleID, email string
if err := rows.Scan(&particleID, &email); err != nil {
return nil, err
}
result[particleID] = append(result[particleID], email)
}
return result, rows.Err()
}
func (r *repositoryImpl) getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error) {
rows, err := r.pool.Query(ctx, `
WITH RECURSIVE ancestors AS (
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
FROM particles WHERE id = $1
UNION ALL
SELECT p.id, p.type, p.network_id, p.parent_id, p.created_by_email, p.visibility, p.data, p.updated_at, p.created_at
FROM particles p JOIN ancestors a ON p.id = a.parent_id
)
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
FROM ancestors;
`, particleID)
if err != nil {
return nil, err
}
defer rows.Close()
particles, err := scanParticles(rows)
if err != nil {
return nil, err
}
if len(particles) == 0 {
return nil, errNotFound
}
return particles, nil
}
func (r *repositoryImpl) isMemberOf(ctx context.Context, particleID, email string) (bool, error) {
var isMember bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM particle_members WHERE particle_id = $1 AND email = $2)
`, particleID, email).Scan(&isMember)
return isMember, err
}
func (r *repositoryImpl) markSeen(ctx context.Context, particleID, email string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO particle_seen (particle_id, email) VALUES ($1, $2)
ON CONFLICT (particle_id, email) DO NOTHING`,
particleID, email,
)
return err
}
func (r *repositoryImpl) getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error) {
if len(particleIDs) == 0 {
return map[string]bool{}, nil
}
rows, err := r.pool.Query(ctx,
`SELECT particle_id FROM particle_seen WHERE particle_id = ANY($1) AND email = $2`,
particleIDs, email,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result[id] = true
}
return result, rows.Err()
}
func (r *repositoryImpl) getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error) {
if len(streamIDs) == 0 {
return map[string]int{}, nil
}
rows, err := r.pool.Query(ctx, `
SELECT p.parent_id, COUNT(*)
FROM particles p
WHERE p.parent_id = ANY($1)
AND NOT EXISTS (SELECT 1 FROM particle_seen ps WHERE ps.particle_id = p.id AND ps.email = $2)
AND (p.visibility = 'network_all' OR p.visibility = 'inherited'
OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $2))
GROUP BY p.parent_id
`, streamIDs, email)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]int)
for rows.Next() {
var parentID string
var count int
if err := rows.Scan(&parentID, &count); err != nil {
return nil, err
}
result[parentID] = count
}
return result, rows.Err()
}
func (r *repositoryImpl) ack(ctx context.Context, particleID, email string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO particle_acks (particle_id, email) VALUES ($1, $2)
ON CONFLICT (particle_id, email) DO NOTHING`,
particleID, email,
)
return err
}
func (r *repositoryImpl) getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
if len(particleIDs) == 0 {
return map[string][]AckInfo{}, nil
}
rows, err := r.pool.Query(ctx,
`SELECT particle_id, email, acked_at FROM particle_acks WHERE particle_id = ANY($1) ORDER BY acked_at`,
particleIDs,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string][]AckInfo)
for rows.Next() {
var particleID string
var info AckInfo
if err := rows.Scan(&particleID, &info.Email, &info.AckedAt); err != nil {
return nil, err
}
result[particleID] = append(result[particleID], info)
}
return result, rows.Err()
}
+954
View File
@@ -0,0 +1,954 @@
package particle
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultPageSize = 50
type Service interface {
// Create creates a new particle. Caller must be a network member (verified by handler).
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
// GetByID returns ErrNotFound or ErrAccessDenied.
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
// Delete returns ErrNotFound or ErrAccessDenied.
Delete(ctx context.Context, id, requesterEmail string) error
// List returns particles in a network. Use parentID=nil for root particles.
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
OpenStream(ctx context.Context, id, requesterEmail string) error
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
CloseStream(ctx context.Context, id, requesterEmail string) error
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// Seen tracking (private)
MarkSeen(ctx context.Context, id, requesterEmail string) error
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
// Ack tracking (public, permanent)
Ack(ctx context.Context, id, requesterEmail string) error
// Unseen counts for stream list view
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
// Bulk lookups for handler enrichment
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
}
type serviceImpl struct {
repo repository
networkSvc network.Service
}
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
return &serviceImpl{
repo: newRepository(pool),
networkSvc: networkSvc,
}
}
// checkAccess verifies that the email has access to the particle based on visibility.
// Assumes the caller is already verified as a network member (handler responsibility).
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
// network_all or custom node.
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil {
return false, err
}
if len(ancestors) == 0 {
return false, errNotFound
}
// Build lookup map by ID
byID := make(map[string]*Particle, len(ancestors))
for _, p := range ancestors {
byID[p.ID] = p
}
// Start from the target particle (first in chain) and walk up on inherited
current := ancestors[0]
for {
switch current.Visibility {
case VisibilityNetworkAll:
return true, nil
case VisibilityCustom:
return s.repo.isMemberOf(ctx, current.ID, email)
case VisibilityInherited:
if current.ParentID == nil {
// inherited at root is invalid state, deny access
return false, nil
}
parent, ok := byID[*current.ParentID]
if !ok {
return false, nil
}
current = parent
default:
return false, nil
}
}
}
func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
// Validate particle type
if !isValidParticleType(input.Type) {
return nil, ErrInvalidType
}
// Validate data matches type requirements
if err := validateParticleData(input.Type, input.Data); err != nil {
return nil, err
}
// MVP visibility rules:
// - Child particles (have parent) → always inherited
// - Root particles (no parent) → cannot be inherited, default network_all
if input.ParentID != nil {
// Children always inherit from parent
input.Visibility = VisibilityInherited
input.Members = nil // no members on inherited particles
// Reject streams and folders as children (MVP: streams are root-level only)
if input.Type == TypeStream || input.Type == TypeFolder {
return nil, ErrInvalidParent
}
} else {
// Root particles cannot be inherited
if input.Visibility == VisibilityInherited {
return nil, ErrInheritedAtRoot
}
if input.Visibility == "" {
input.Visibility = VisibilityNetworkAll
}
}
// Custom visibility requires at least one member and must be a stream
if input.Visibility == VisibilityCustom {
if input.Type != TypeStream {
return nil, ErrNotAContainer
}
if len(input.Members) == 0 {
return nil, ErrMembersRequired
}
}
// Network membership is verified by handler - we only check particle visibility
// If parent specified, check parent access (visibility-based)
if input.ParentID != nil {
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrInvalidParent
}
return nil, err
}
if !hasAccess {
return nil, ErrAccessDenied
}
}
// Build the particle
p := &Particle{
Type: input.Type,
NetworkID: input.NetworkID,
ParentID: input.ParentID,
CreatedByEmail: requesterEmail,
Visibility: input.Visibility,
Data: input.Data,
}
if p.Data == nil {
p.Data = json.RawMessage("{}")
}
// For streams, set initial status to open and check capacity
if input.Type == TypeStream {
// Set status to open in the data JSON
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil {
return nil, err
}
p.Data = data
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return nil, ErrCapacityExceeded
}
return nil, err
}
}
// Create the particle
created, err := s.repo.create(ctx, p)
if err != nil {
// If we incremented the stream count but creation failed, decrement it
if input.Type == TypeStream {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
}
}
return nil, err
}
// Add members if custom visibility (only streams for MVP)
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
normalizedEmails := make([]string, 0, len(input.Members)+1)
// Always include the creator
normalizedEmails = append(normalizedEmails, requesterEmail)
for _, email := range input.Members {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
continue // Skip invalid emails
}
if normalized == requesterEmail {
continue // Already added
}
normalizedEmails = append(normalizedEmails, normalized)
}
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
return nil, err
}
}
return created, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if !hasAccess {
return nil, ErrAccessDenied
}
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return p, nil
}
func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if !hasAccess {
return nil, ErrAccessDenied
}
// Get the particle to validate data against its type
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
// Validate data matches type requirements
if err := validateParticleData(p.Type, data); err != nil {
return nil, err
}
err = s.repo.update(ctx, id, data, time.Now())
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return s.repo.getByID(ctx, id)
}
func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle to check if it's an open stream
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// If it's an open stream, decrement the count
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
return err
}
}
err = s.repo.delete(ctx, id)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
// Network membership is verified by handler - we only check particle visibility
// If parentID specified, check access to parent (visibility-based)
if parentID != nil {
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if !hasAccess {
return nil, ErrAccessDenied
}
}
// Fetch one extra to determine if there are more
// Access filtering is done in the query itself (network_all OR user is member)
if limit == 0 {
limit = defaultPageSize
}
extraLimit := limit + 1
particles, err := s.repo.list(ctx, networkID, parentID, requesterEmail, filter, extraLimit, cursor)
if err != nil {
return nil, err
}
hasMore := len(particles) > limit
result := &ParticleList{
HasMore: hasMore,
}
if hasMore {
particles = particles[:limit]
}
result.Particles = particles
// Bidirectional cursors
if len(particles) > 0 {
firstParticle := particles[0]
lastParticle := particles[len(particles)-1]
result.PrevCursor = &Cursor{
Position: firstParticle.UpdatedAt.Format(time.RFC3339Nano),
Direction: "before",
}
if result.HasMore {
result.NextCursor = &Cursor{
Position: lastParticle.UpdatedAt.Format(time.RFC3339Nano),
Direction: "after",
}
}
}
return result, nil
}
func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if p.Type != TypeStream {
return ErrNotAStream
}
if getStreamStatus(p.Data) == string(StreamStatusOpen) {
return ErrStreamAlreadyOpen
}
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return ErrCapacityExceeded
}
return err
}
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
return err
}
err = s.repo.update(ctx, id, newData, time.Now())
if err != nil {
// Rollback the capacity increment
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
return nil
}
func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if p.Type != TypeStream {
return ErrNotAStream
}
if getStreamStatus(p.Data) == string(StreamStatusClosed) {
return ErrStreamAlreadyClosed
}
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
if err != nil {
return err
}
err = s.repo.update(ctx, id, newData, time.Now())
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// Decrement capacity
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
}
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle to check constraints
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// Root particles cannot be inherited
if mode == VisibilityInherited && p.ParentID == nil {
return ErrInheritedAtRoot
}
// If expanding to network_all, check that parent's effective visibility allows it
if mode == VisibilityNetworkAll && p.ParentID != nil {
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
if err != nil {
return err
}
if parentVis == VisibilityCustom {
return ErrAccessExpansion
}
}
err = s.repo.setVisibility(ctx, id, mode)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil {
return "", err
}
if len(ancestors) == 0 {
return "", errNotFound
}
byID := make(map[string]*Particle, len(ancestors))
for _, p := range ancestors {
byID[p.ID] = p
}
current := ancestors[0]
for {
if current.Visibility != VisibilityInherited {
return current.Visibility, nil
}
if current.ParentID == nil {
return VisibilityNetworkAll, nil
}
parent, ok := byID[*current.ParentID]
if !ok {
return VisibilityNetworkAll, nil
}
current = parent
}
}
func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle to check type and parent access
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// Only streams can have members
if p.Type != TypeStream {
return ErrNotAContainer
}
// Validate and normalize emails, check network membership
normalizedEmails := make([]string, 0, len(emails))
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
continue
}
// Root stream - check network membership
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
if err != nil || !isMember {
continue
}
normalizedEmails = append(normalizedEmails, normalized)
}
if len(normalizedEmails) == 0 {
return nil
}
return s.repo.addMembers(ctx, id, normalizedEmails)
}
func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Get the particle to check type
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// Only streams can have members
if p.Type != TypeStream {
return ErrNotAContainer
}
normalizedEmails := make([]string, 0, len(emails))
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
continue
}
normalizedEmails = append(normalizedEmails, normalized)
}
if len(normalizedEmails) == 0 {
return nil
}
return s.repo.removeMembers(ctx, id, normalizedEmails)
}
func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
return s.repo.markSeen(ctx, id, requesterEmail)
}
func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access for each particle and mark seen
for _, id := range ids {
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
continue // Skip non-existent particles
}
return err
}
if !hasAccess {
continue // Skip inaccessible particles
}
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
return err
}
}
return nil
}
func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
if !hasAccess {
return ErrAccessDenied
}
// Ack also marks as seen
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
return err
}
return s.repo.ack(ctx, id, requesterEmail)
}
func (s *serviceImpl) GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
return s.repo.getUnseenCounts(ctx, streamIDs, requesterEmail)
}
func (s *serviceImpl) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) {
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
if err != nil {
return nil, err
}
return s.repo.getSeenMap(ctx, particleIDs, requesterEmail)
}
func (s *serviceImpl) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
return s.repo.getAcksMap(ctx, particleIDs)
}
func (s *serviceImpl) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
return s.repo.getMembersMap(ctx, particleIDs)
}
func isValidParticleType(t ParticleType) bool {
switch t {
case TypeStream, TypeFolder, TypeMedia, TypeFile, TypeText, TypeQuest, TypePaper:
return true
default:
return false
}
}
// getStreamStatus extracts the status from a stream particle's data
func getStreamStatus(data json.RawMessage) string {
var d StreamData
if err := json.Unmarshal(data, &d); err != nil {
return ""
}
return d.Status
}
// setStreamStatus updates the status in a stream particle's data
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
var d StreamData
if err := json.Unmarshal(data, &d); err != nil {
d = StreamData{}
}
d.Status = status
return json.Marshal(d)
}
// validateParticleData validates that the data field contains valid JSON
// and has required fields for the given particle type.
func validateParticleData(pType ParticleType, data json.RawMessage) error {
// Empty or null data is allowed - will default to {}
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
return nil
}
switch pType {
case TypeStream:
var d StreamData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.Name == "" {
return errors.Join(ErrInvalidData, errors.New("stream requires name"))
}
if d.Status != string(StreamStatusOpen) && d.Status != string(StreamStatusClosed) {
return errors.Join(ErrInvalidData, errors.New("stream requires valid status"))
}
case TypeFolder:
var d FolderData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.Name == "" {
return errors.Join(ErrInvalidData, errors.New("folder requires name"))
}
case TypeMedia:
var d MediaData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.ObjectID == "" {
return errors.Join(ErrInvalidData, errors.New("media requires object_id"))
}
if d.MimeType == "" {
return errors.Join(ErrInvalidData, errors.New("media requires mime_type"))
}
if d.DurationMs <= 0 {
return errors.Join(ErrInvalidData, errors.New("media requires positive duration_ms"))
}
case TypeFile:
var d FileData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.ObjectID == "" {
return errors.Join(ErrInvalidData, errors.New("file requires object_id"))
}
if d.Filename == "" {
return errors.Join(ErrInvalidData, errors.New("file requires filename"))
}
if d.MimeType == "" {
return errors.Join(ErrInvalidData, errors.New("file requires mime_type"))
}
if d.Size <= 0 {
return errors.Join(ErrInvalidData, errors.New("file requires non-negative size"))
}
case TypeText:
var d TextData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.Content == "" {
return errors.Join(ErrInvalidData, errors.New("text requires content"))
}
case TypeQuest:
var d QuestData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.Title == "" {
return errors.Join(ErrInvalidData, errors.New("quest requires title"))
}
if d.Description == "" {
return errors.Join(ErrInvalidData, errors.New("quest requires description"))
}
case TypePaper:
var d PaperData
if err := json.Unmarshal(data, &d); err != nil {
return errors.Join(ErrInvalidData, err)
}
if d.Title == "" {
return errors.Join(ErrInvalidData, errors.New("paper requires title"))
}
if d.Content == "" {
return errors.Join(ErrInvalidData, errors.New("paper requires content"))
}
}
return nil
}
+399
View File
@@ -0,0 +1,399 @@
package particle_test
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
)
var dbPool *pgxpool.Pool
func TestMain(m *testing.M) {
dbPool = testhelper.SetupTestDB()
defer testhelper.TeardownTestDB()
ret := m.Run()
os.Exit(ret)
}
func getStreamStatus(data json.RawMessage) string {
var d struct {
Status string `json:"status"`
}
json.Unmarshal(data, &d)
return d.Status
}
func TestParticleService_CreateAndGet(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network first
net, err := networkSvc.Create(ctx, "Test Network", "[email protected]")
assert.NoError(t, err)
// Test Create stream particle
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: data,
}
created, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, created.ID)
assert.Equal(t, particle.TypeStream, created.Type)
assert.Equal(t, net.ID, created.NetworkID)
assert.Nil(t, created.ParentID)
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
// Test GetByID
found, err := svc.GetByID(ctx, created.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, created.ID, found.ID)
// Test GetByID with non-existent id
_, err = svc.GetByID(ctx, "particle_nonexistent", "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrNotFound)
// Note: Network membership check is handler's responsibility
// Service assumes caller is already verified as network member
}
func TestParticleService_StreamCapacity(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with capacity 2
net, err := networkSvc.Create(ctx, "Capacity Test Network", "[email protected]")
assert.NoError(t, err)
err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2)
assert.NoError(t, err)
// Create first stream - should succeed
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`),
}
stream1, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Create second stream - should succeed
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
stream2, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Create third stream - should fail with capacity exceeded
input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`)
_, err = svc.Create(ctx, input, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
// Close a stream
err = svc.CloseStream(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
// Now we can create another stream
stream3, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, stream3.ID)
// Verify stream2 is still open
found, err := svc.GetByID(ctx, stream2.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
// Verify stream1 is closed
found, err = svc.GetByID(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
}
func TestParticleService_NestedParticles(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
net, err := networkSvc.Create(ctx, "Nested Test Network", "[email protected]")
assert.NoError(t, err)
// Create a parent stream
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, streamInput, "[email protected]")
assert.NoError(t, err)
// Create a text particle as child
textInput := particle.CreateInput{
Type: particle.TypeText,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Hello world"}`),
}
text, err := svc.Create(ctx, textInput, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, stream.ID, *text.ParentID)
// Create a file as child of stream
fileInput := particle.CreateInput{
Type: particle.TypeFile,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
}
file, err := svc.Create(ctx, fileInput, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, stream.ID, *file.ParentID)
// List children of stream
children, err := svc.List(ctx, net.ID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
assert.NoError(t, err)
assert.Len(t, children.Particles, 2)
}
func TestParticleService_CustomVisibility(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with a member
net, err := networkSvc.Create(ctx, "Visibility Test Network", "[email protected]")
assert.NoError(t, err)
err = networkSvc.AddMembers(ctx, net.ID, []string{"[email protected]", "[email protected]"})
assert.NoError(t, err)
// Create a stream with custom visibility including only admin and member
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]", "[email protected]"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, streamInput, "[email protected]")
assert.NoError(t, err)
// Admin can access
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
// Member can access
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
// Other network member cannot access
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrAccessDenied)
// Non-network member cannot access
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrAccessDenied)
}
func TestParticleService_UpdateAndDelete(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
net, err := networkSvc.Create(ctx, "Update Test Network", "[email protected]")
assert.NoError(t, err)
// Create a text particle
input := particle.CreateInput{
Type: particle.TypeText,
NetworkID: net.ID,
Data: json.RawMessage(`{"content":"Original content"}`),
}
created, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Update the particle
newData := json.RawMessage(`{"content":"Updated content"}`)
updated, err := svc.Update(ctx, created.ID, newData, "[email protected]")
assert.NoError(t, err)
// PostgreSQL normalizes JSON, so compare unmarshaled values
var expected, actual map[string]interface{}
json.Unmarshal(newData, &expected)
json.Unmarshal(updated.Data, &actual)
assert.Equal(t, expected, actual)
// Delete the particle
err = svc.Delete(ctx, created.ID, "[email protected]")
assert.NoError(t, err)
// Verify it's gone
_, err = svc.GetByID(ctx, created.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrNotFound)
}
func TestParticleService_ListRootParticles(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
net, err := networkSvc.Create(ctx, "List Test Network", "[email protected]")
assert.NoError(t, err)
// Create multiple root particles
for i := 0; i < 3; i++ {
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}
_, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
}
// List root particles (parentID = nil)
list, err := svc.List(ctx, net.ID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(list.Particles), 3)
}
func TestParticleService_OpenCloseStream(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
net, err := networkSvc.Create(ctx, "Open Close Test Network", "[email protected]")
assert.NoError(t, err)
// Create a stream
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(stream.Data))
// Close the stream
err = svc.CloseStream(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
// Verify it's closed
found, err := svc.GetByID(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
// Try to close again - should error
err = svc.CloseStream(ctx, stream.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrStreamAlreadyClosed)
// Reopen the stream
err = svc.OpenStream(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
// Verify it's open
found, err = svc.GetByID(ctx, stream.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
// Try to open again - should error
err = svc.OpenStream(ctx, stream.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrStreamAlreadyOpen)
}
func TestParticleService_NotAStream(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "[email protected]")
assert.NoError(t, err)
// Create a text particle
input := particle.CreateInput{
Type: particle.TypeText,
NetworkID: net.ID,
Data: json.RawMessage(`{"content":"Hello"}`),
}
text, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Try to open it as a stream
err = svc.OpenStream(ctx, text.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrNotAStream)
// Try to close it as a stream
err = svc.CloseStream(ctx, text.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrNotAStream)
}
func TestParticleService_AccessInheritance(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with members
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "[email protected]")
assert.NoError(t, err)
err = networkSvc.AddMembers(ctx, net.ID, []string{"[email protected]", "[email protected]"})
assert.NoError(t, err)
// Create a stream with custom visibility (admin and member only)
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]", "[email protected]"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, streamInput, "[email protected]")
assert.NoError(t, err)
// Create a child text (network_all visibility)
textInput := particle.CreateInput{
Type: particle.TypeText,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Child text"}`),
}
text, err := svc.Create(ctx, textInput, "[email protected]")
assert.NoError(t, err)
// Admin can access child
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
assert.NoError(t, err)
// Member can access child
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
assert.NoError(t, err)
// Other cannot access child (even though child is network_all, parent restricts)
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrAccessDenied)
}