package postgres import ( "context" "database/sql" "fmt" "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) type WorkspaceStore struct{} func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── columns ──────────────────────────────── const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, created_at, updated_at` const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at` // ── scanners ─────────────────────────────── func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { var w models.Workspace var maxBytes sql.NullInt64 err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, &w.CreatedAt, &w.UpdatedAt, ) if err != nil { return nil, err } if maxBytes.Valid { w.MaxBytes = &maxBytes.Int64 } return &w, nil } func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { var f models.WorkspaceFile var contentType sql.NullString var sha256 sql.NullString var indexStatus sql.NullString err := row.Scan( &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, &contentType, &f.SizeBytes, &sha256, &indexStatus, &f.ChunkCount, &f.CreatedAt, &f.UpdatedAt, ) if err != nil { return nil, err } if contentType.Valid { f.ContentType = contentType.String } if sha256.Valid { f.SHA256 = sha256.String } if indexStatus.Valid { f.IndexStatus = indexStatus.String } return &f, nil } // ── Workspace CRUD ───────────────────────── func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { // Accept pre-generated ID or let Postgres generate one if w.ID != "" { return DB.QueryRowContext(ctx, ` INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at, updated_at`, w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, ).Scan(&w.CreatedAt, &w.UpdatedAt) } return DB.QueryRowContext(ctx, ` INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at`, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, ).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt) } func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id) return scanWorkspace(row) } func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { b := NewUpdate("workspaces") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.MaxBytes != nil { b.Set("max_bytes", *patch.MaxBytes) } if patch.Status != nil { b.Set("status", *patch.Status) } if patch.IndexingEnabled != nil { b.Set("indexing_enabled", *patch.IndexingEnabled) } if !b.HasSets() { return nil } b.Set("updated_at", time.Now().UTC()) b.Where("id", id) _, err := b.Exec(DB) return err } func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id) return err } // ── Ownership lookup ─────────────────────── func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' ORDER BY created_at DESC LIMIT 1`, ownerType, ownerID) return scanWorkspace(row) } func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { rows, err := DB.QueryContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' ORDER BY created_at DESC`, ownerType, ownerID) if err != nil { return nil, err } defer rows.Close() var out []models.Workspace for rows.Next() { w, err := scanWorkspace(rows) if err != nil { return nil, err } out = append(out, *w) } return out, rows.Err() } // ── File index ───────────────────────────── func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { return DB.QueryRowContext(ctx, ` INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, path) DO UPDATE SET is_directory = EXCLUDED.is_directory, content_type = EXCLUDED.content_type, size_bytes = EXCLUDED.size_bytes, sha256 = EXCLUDED.sha256, updated_at = NOW() RETURNING id, created_at, updated_at`, f.WorkspaceID, f.Path, f.IsDirectory, models.NullString(&f.ContentType), f.SizeBytes, models.NullString(&f.SHA256), ).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt) } func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`, workspaceID, path) return err } func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`, workspaceID, prefix+"%") return err } func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = $1 AND path = $2`, workspaceID, path) return scanWorkspaceFile(row) } func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { var query string var args []interface{} if recursive { // All files under prefix (recursive) if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 ORDER BY path` args = []interface{}{workspaceID} } else { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path` args = []interface{}{workspaceID, prefix + "%"} } } else { // Direct children only: match prefix but no additional slashes if prefix == "" { // Root level: no slash in path query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path` args = []interface{}{workspaceID} } else { // Children of prefix: starts with prefix, no additional slash after prefix query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2 AND substring(path FROM %d) NOT LIKE '%%/%%' ORDER BY path`, len(prefix)+1) args = []interface{}{workspaceID, prefix + "%"} } } rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []models.WorkspaceFile for rows.Next() { f, err := scanWorkspaceFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID) return err } // ── Stats ────────────────────────────────── func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { stats := &models.WorkspaceStats{WorkspaceID: workspaceID} err := DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(size_bytes), 0) FROM workspace_files WHERE workspace_id = $1`, workspaceID, ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) if err != nil { return nil, err } // Fetch quota var maxBytes sql.NullInt64 err = DB.QueryRowContext(ctx, `SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID, ).Scan(&maxBytes) if err != nil && err != sql.ErrNoRows { return nil, err } if maxBytes.Valid { stats.MaxBytes = &maxBytes.Int64 } return stats, nil } // ── Chunks (v0.21.2) ───────────────────────── func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { if len(chunks) == 0 { return nil } const cols = 8 // workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at valueParts := make([]string, 0, len(chunks)) args := make([]interface{}, 0, len(chunks)*cols) for i, c := range chunks { base := i * cols valueParts = append(valueParts, fmt.Sprintf( "($%d, $%d, $%d, $%d, $%d, $%d::vector, $%d, NOW())", base+1, base+2, base+3, base+4, base+5, base+6, base+7, )) args = append(args, c.WorkspaceID, c.FileID, c.ChunkIndex, c.Content, c.TokenCount, vectorToString(c.Embedding), ToJSON(c.Metadata)) } q := fmt.Sprintf(`INSERT INTO workspace_chunks (workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at) VALUES %s`, strings.Join(valueParts, ", ")) _, err := DB.ExecContext(ctx, q, args...) return err } func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = $1`, fileID) return err } func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { if len(queryVec) == 0 { return nil, nil } if limit <= 0 { limit = 10 } rows, err := DB.QueryContext(ctx, ` SELECT f.path, c.content, 1 - (c.embedding <=> $1::vector) AS similarity, c.metadata FROM workspace_chunks c JOIN workspace_files f ON c.file_id = f.id WHERE c.workspace_id = $2 AND 1 - (c.embedding <=> $1::vector) > $3 ORDER BY c.embedding <=> $1::vector LIMIT $4`, vectorToString(queryVec), workspaceID, threshold, limit, ) if err != nil { return nil, err } defer rows.Close() var results []models.WorkspaceChunkResult for rows.Next() { var r models.WorkspaceChunkResult var metadataJSON []byte if err := rows.Scan(&r.FilePath, &r.Content, &r.Score, &metadataJSON); err != nil { return nil, err } ScanJSON(metadataJSON, &r.Metadata) if r.Metadata != nil { if lh, ok := r.Metadata["line_start"]; ok { if n, ok := lh.(float64); ok { r.LineHint = int(n) } } } results = append(results, r) } return results, rows.Err() } func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { _, err := DB.ExecContext(ctx, `UPDATE workspace_files SET index_status = $1, chunk_count = $2, updated_at = NOW() WHERE id = $3`, status, chunkCount, fileID) return err }